package artifact
import (
"archive/zip"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/mclucy/lucy/catalog"
)
// SupportsPath reports whether path has a supported artifact archive
// extension.
func SupportsPath(path string) bool {
return supportedExt(strings.ToLower(filepath.Ext(path)))
}
func supportedExt(ext string) bool {
switch ext {
case ".jar", ".zip", ".pyz", ".mcdr":
return true
default:
return false
}
}
// ReadInfo extracts package metadata from an artifact file. For .jar/.zip
// files all readers are tried; for .pyz/.mcdr only the MCDR reader runs.
func ReadInfo(filePath string, opts ...Option) ([]PackageInfo, error) {
o := &options{}
for _, opt := range opts {
opt(o)
}
_ = o
ext := strings.ToLower(filepath.Ext(filePath))
if !supportedExt(ext) {
return nil, fmt.Errorf("unsupported artifact format: %s", ext)
}
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("open artifact: %w", err)
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("stat artifact: %w", err)
}
zipReader, err := zip.NewReader(file, stat.Size())
if err != nil {
return nil, fmt.Errorf("read zip: %w", err)
}
var results []PackageInfo
if ext == ".pyz" || ext == ".mcdr" {
mcdrReader := newMcdrReader()
results, err := mcdrReader.Read(zipReader, filePath)
if err != nil {
return nil, err
}
return results, nil
}
for _, r := range readers {
infos, err := r.Read(zipReader, filePath)
if err != nil {
continue
}
results = append(results, infos...)
}
if jarEcosystemsConflict(results) {
return nil, fmt.Errorf(
"ambiguous artifact %q: packages span incompatible ecosystems",
filePath,
)
}
return results, nil
}
// jarEcosystemsConflict returns true when the detected artifacts span two or more
// ecosystem families that cannot coexist in a single deployable JAR.
//
// Ecosystem families:
//
// proxyFamily – velocity, bungeecord
// serverFamily – bukkit, paper
// modFamily – fabric, forge, neoforge
//
// Artifacts whose ecosystem is catalog.EcosystemUnspecified are excluded from
// the conflict check.
func jarEcosystemsConflict(infos []PackageInfo) bool {
var hasProxy, hasServer, hasMod bool
for _, info := range infos {
switch info.Ref.Ecosystem {
case catalog.EcosystemVelocity, catalog.EcosystemBungeeCord:
hasProxy = true
case catalog.EcosystemBukkit, catalog.EcosystemPaper:
hasServer = true
case catalog.EcosystemFabric, catalog.EcosystemForge, catalog.EcosystemNeoForge:
hasMod = true
}
}
families := 0
if hasProxy {
families++
}
if hasServer {
families++
}
if hasMod {
families++
}
return families > 1
}
package artifact
import (
"archive/zip"
"bytes"
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"os/exec"
"path/filepath"
"slices"
"sort"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/version"
"github.com/pelletier/go-toml"
"gopkg.in/yaml.v3"
)
// ReadModules keeps each descriptor identity and nested container location
// intact.
func ReadModules(ctx context.Context, filename string, loader catalog.Ecosystem) ([]catalog.DescriptorModule, error) {
if strings.EqualFold(filepath.Ext(filename), ".py") {
modules, err := inspectPython(ctx, filename)
if err != nil {
return nil, err
}
return normalizeModules(modules), nil
}
z, err := zip.OpenReader(filename)
if err != nil {
// Content-addressed storage names an object by its digest, not the
// artifact's filename. An MCDR plugin source is not an archive, so
// fall back to its static metadata when the extension is absent.
if loader == catalog.EcosystemMCDR {
if modules, pythonErr := inspectPython(ctx, filename); pythonErr == nil {
return normalizeModules(modules), nil
}
}
return nil, err
}
defer z.Close()
modules, err := inspectArchive(&z.Reader, loader, "", 0)
if err != nil {
return nil, err
}
return normalizeModules(modules), nil
}
// normalizeModules makes descriptor order independent of map iteration. The
// lock and plan hashes depend on this order.
func normalizeModules(modules []catalog.DescriptorModule) []catalog.DescriptorModule {
for i := range modules {
slices.Sort(modules[i].Provides)
deps := modules[i].Dependencies
sort.Slice(deps, func(a, b int) bool {
return compareDescriptorDependency(deps[a], deps[b]) < 0
})
}
sort.Slice(modules, func(i, j int) bool {
return compareDescriptorModule(modules[i], modules[j]) < 0
})
return modules
}
func compareDescriptorModule(a, b catalog.DescriptorModule) int {
if n := strings.Compare(a.NestedPath, b.NestedPath); n != 0 {
return n
}
if n := strings.Compare(a.DescriptorPath, b.DescriptorPath); n != 0 {
return n
}
if n := strings.Compare(a.ID, b.ID); n != 0 {
return n
}
if n := strings.Compare(string(a.Version), string(b.Version)); n != 0 {
return n
}
return strings.Compare(string(a.Ecosystem), string(b.Ecosystem))
}
func compareDescriptorDependency(a, b catalog.DescriptorDependency) int {
if n := strings.Compare(string(a.Kind), string(b.Kind)); n != 0 {
return n
}
if n := strings.Compare(a.ID, b.ID); n != 0 {
return n
}
if n := strings.Compare(a.Constraint, b.Constraint); n != 0 {
return n
}
if n := strings.Compare(a.Ordering, b.Ordering); n != 0 {
return n
}
if n := strings.Compare(a.Phase, b.Phase); n != 0 {
return n
}
if n := strings.Compare(a.Load, b.Load); n != 0 {
return n
}
if a.JoinClasspath != b.JoinClasspath {
if a.JoinClasspath {
return 1
}
return -1
}
if n := strings.Compare(string(a.Provider), string(b.Provider)); n != 0 {
return n
}
if n := strings.Compare(a.ProjectID, b.ProjectID); n != 0 {
return n
}
return strings.Compare(a.ModuleID, b.ModuleID)
}
func nativeEntry(z *zip.Reader, name string) ([]byte, error) {
raw, _, err := fsutil.ReadZipEntry(
z,
name,
fsutil.MaxNestedZipEntryBytes,
)
return raw, err
}
func inspectArchive(z *zip.Reader, loader catalog.Ecosystem, member string, depth int) ([]catalog.DescriptorModule, error) {
if depth > 16 {
return nil, fmt.Errorf("nested archive depth exceeds 16")
}
switch loader {
case catalog.EcosystemFabric:
raw, err := nativeEntry(z, "fabric.mod.json")
if err != nil {
return nil, err
}
if raw == nil {
return nil, nil
}
var d struct {
ID string `json:"id"`
Version string `json:"version"`
Environment string `json:"environment"`
Provides []string `json:"provides"`
Jars []struct {
File string `json:"file"`
} `json:"jars"`
Depends map[string]jsontext.Value `json:"depends"`
Recommends map[string]jsontext.Value `json:"recommends"`
Suggests map[string]jsontext.Value `json:"suggests"`
Breaks map[string]jsontext.Value `json:"breaks"`
Conflicts map[string]jsontext.Value `json:"conflicts"`
}
if err := json.Unmarshal(raw, &d); err != nil {
return nil, err
}
side := catalog.ParseSide(d.Environment)
if side == catalog.SideClient {
return nil, nil
}
m := catalog.DescriptorModule{
ID: d.ID,
Version: version.Version(d.Version),
Ecosystem: loader,
VersionDialect: version.DialectFabric,
NestedPath: member,
DescriptorPath: "fabric.mod.json",
Provides: d.Provides,
Side: side,
Dependencies: []catalog.DescriptorDependency{},
}
for kind, deps := range map[catalog.DependencyKind]map[string]jsontext.Value{
catalog.DependencyKindRequired: d.Depends,
catalog.DependencyKindRecommended: d.Recommends,
catalog.DependencyKindSuggested: d.Suggests,
catalog.DependencyKindIncompatible: d.Breaks,
catalog.DependencyKindConflict: d.Conflicts,
} {
for id, raw := range deps {
var one string
var many []string
if err := json.Unmarshal(raw, &one); err == nil {
many = []string{one}
} else if err := json.Unmarshal(raw, &many); err != nil {
return nil, err
}
m.Dependencies = append(m.Dependencies, catalog.DescriptorDependency{
ID: id,
Kind: kind,
Constraint: strings.Join(many, " || "),
})
}
}
out := []catalog.DescriptorModule{m}
for _, jar := range d.Jars {
raw, err := nativeEntry(z, jar.File)
if err != nil {
return nil, err
}
if raw == nil {
return nil, fmt.Errorf("missing nested Fabric JAR %s", jar.File)
}
nested, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
return nil, err
}
p := jar.File
if member != "" {
p = member + "/" + p
}
mods, err := inspectArchive(nested, loader, p, depth+1)
if err != nil {
return nil, err
}
out = append(out, mods...)
}
return out, nil
case catalog.EcosystemForge, catalog.EcosystemNeoForge:
name := "META-INF/mods.toml"
if loader == catalog.EcosystemNeoForge {
if raw, err := nativeEntry(z, "META-INF/neoforge.mods.toml"); err != nil {
return nil, err
} else if raw != nil {
name = "META-INF/neoforge.mods.toml"
}
}
raw, err := nativeEntry(z, name)
if err != nil {
return nil, err
}
var out []catalog.DescriptorModule
if raw != nil {
var d struct {
Mods []struct {
ID string `toml:"modId"`
Version string `toml:"version"`
} `toml:"mods"`
Dependencies map[string][]struct {
ID string `toml:"modId"`
Mandatory bool `toml:"mandatory"`
Type string `toml:"type"`
Range string `toml:"versionRange"`
Ordering string `toml:"ordering"`
Side string `toml:"side"`
} `toml:"dependencies"`
}
if err := toml.Unmarshal(raw, &d); err != nil {
return nil, err
}
for _, mod := range d.Mods {
v := version.Version(mod.Version)
if v == "${file.jarVersion}" {
data, err := nativeEntry(z, "META-INF/MANIFEST.MF")
if err != nil {
return nil, err
}
v = forgeManifestVersion(string(data))
}
m := catalog.DescriptorModule{
ID: mod.ID,
Version: v,
Ecosystem: loader,
VersionDialect: version.DialectMaven,
NestedPath: member,
DescriptorPath: name,
Dependencies: []catalog.DescriptorDependency{},
}
for _, dep := range d.Dependencies[mod.ID] {
if catalog.ParseSide(dep.Side) == catalog.SideClient {
continue
}
kind := catalog.ParseDependencyKind(dep.Type)
if kind == catalog.DependencyKindUnknown {
kind = catalog.DependencyKindOptional
if dep.Mandatory {
kind = catalog.DependencyKindRequired
}
}
m.Dependencies = append(m.Dependencies, catalog.DescriptorDependency{
ID: dep.ID,
Constraint: dep.Range,
Kind: kind,
Ordering: dep.Ordering,
})
}
out = append(out, m)
}
}
if loader == catalog.EcosystemNeoForge {
// A jar without its own descriptor may still ship mods in JarJar
// containers; FML loads those modules from the outer jar.
data, err := nativeEntry(z, "META-INF/jarjar/metadata.json")
if err != nil {
return nil, err
}
if data != nil {
var jj struct {
Jars []struct {
Path string `json:"path"`
} `json:"jars"`
}
if err := json.Unmarshal(data, &jj); err != nil {
return nil, err
}
for _, jar := range jj.Jars {
raw, err := nativeEntry(z, jar.Path)
if err != nil {
return nil, err
}
if raw == nil {
return nil, fmt.Errorf("missing JarJar container %s", jar.Path)
}
nested, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
return nil, err
}
p := jar.Path
if member != "" {
p = member + "/" + p
}
mods, err := inspectArchive(nested, loader, p, depth+1)
if err != nil {
return nil, err
}
out = append(out, mods...)
}
}
}
return out, nil
case catalog.EcosystemBukkit, catalog.EcosystemPaper:
name := "paper-plugin.yml"
raw, err := nativeEntry(z, name)
if err != nil {
return nil, err
}
if raw == nil {
name = "plugin.yml"
raw, err = nativeEntry(z, name)
if err != nil {
return nil, err
}
}
if raw == nil {
return nil, nil
}
var d struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
API string `yaml:"api-version"`
Folia bool `yaml:"folia-supported"`
Provides []string `yaml:"provides"`
Depend []string `yaml:"depend"`
SoftDepend []string `yaml:"softdepend"`
LoadBefore []string `yaml:"loadbefore"`
Dependencies map[string]map[string]struct {
Required *bool `yaml:"required"`
Load string `yaml:"load"`
JoinClasspath *bool `yaml:"join-classpath"`
} `yaml:"dependencies"`
}
if err := yaml.Unmarshal(raw, &d); err != nil {
return nil, err
}
m := catalog.DescriptorModule{
ID: d.Name,
Version: version.Version(d.Version),
Ecosystem: catalog.EcosystemBukkit,
VersionDialect: version.DialectOpaque,
NestedPath: member,
DescriptorPath: name,
APIVersion: d.API,
FoliaSupported: d.Folia,
Provides: d.Provides,
Dependencies: []catalog.DescriptorDependency{},
}
for _, id := range d.Depend {
m.Dependencies = append(m.Dependencies, catalog.DescriptorDependency{
ID: id,
Kind: catalog.DependencyKindRequired,
Ordering: "AFTER",
})
}
for _, id := range d.SoftDepend {
m.Dependencies = append(m.Dependencies, catalog.DescriptorDependency{
ID: id,
Kind: catalog.DependencyKindOptional,
Ordering: "AFTER",
})
}
for _, id := range d.LoadBefore {
m.Dependencies = append(m.Dependencies, catalog.DescriptorDependency{
ID: id,
Kind: catalog.DependencyKindOptional,
Ordering: "BEFORE",
})
}
for phase, deps := range d.Dependencies {
for id, dep := range deps {
kind := catalog.DependencyKindRequired
if dep.Required != nil && !*dep.Required {
kind = catalog.DependencyKindOptional
}
join := dep.JoinClasspath == nil || *dep.JoinClasspath
m.Dependencies = append(m.Dependencies, catalog.DescriptorDependency{
ID: id,
Kind: kind,
Phase: phase,
Load: dep.Load,
JoinClasspath: join,
})
}
}
return []catalog.DescriptorModule{m}, nil
case catalog.EcosystemMCDR:
raw, err := nativeEntry(z, "mcdreforged.plugin.json")
if err != nil {
return nil, err
}
if raw == nil {
return nil, nil
}
return nativeMCDR(z, raw, member)
default:
return nil, fmt.Errorf("unsupported native loader %s", loader)
}
}
func nativeMCDR(
z *zip.Reader,
raw []byte,
member string,
) ([]catalog.DescriptorModule, error) {
var d struct {
ID string `json:"id"`
Version string `json:"version"`
Dependencies map[string]string `json:"dependencies"`
Requirements []string `json:"requirements"`
RequirementsFile jsontext.Value `json:"requirements_file"`
}
if err := json.Unmarshal(raw, &d); err != nil {
return nil, err
}
m := catalog.DescriptorModule{
ID: d.ID,
Version: version.Version(d.Version),
Ecosystem: catalog.EcosystemMCDR,
VersionDialect: version.DialectMCDR,
NestedPath: member,
DescriptorPath: "mcdreforged.plugin.json",
Dependencies: []catalog.DescriptorDependency{},
}
for id, rangeText := range d.Dependencies {
m.Dependencies = append(m.Dependencies, catalog.DescriptorDependency{
ID: id,
Kind: catalog.DependencyKindRequired,
Constraint: rangeText,
})
}
for _, r := range d.Requirements {
m.Dependencies = append(m.Dependencies, catalog.DescriptorDependency{
ID: r,
Kind: catalog.DependencyKindPython,
})
}
if z != nil {
files, err := mcdrRequirementFiles(d.RequirementsFile)
if err != nil {
return nil, err
}
if len(files) == 0 {
files = []string{"requirements.txt"}
}
for _, name := range files {
content, err := nativeEntry(z, name)
if err != nil {
return nil, err
}
if content == nil {
continue
}
for _, requirement := range mcdrRequirements(content) {
m.Dependencies = append(m.Dependencies, catalog.DescriptorDependency{
ID: requirement,
Kind: catalog.DependencyKindPython,
})
}
}
}
return []catalog.DescriptorModule{m}, nil
}
func mcdrRequirementFiles(raw jsontext.Value) ([]string, error) {
if len(raw) == 0 {
return nil, nil
}
var one string
if err := json.Unmarshal(raw, &one); err == nil {
if strings.TrimSpace(one) == "" {
return nil, nil
}
return []string{one}, nil
}
var many []string
if err := json.Unmarshal(raw, &many); err != nil {
return nil, fmt.Errorf("invalid MCDR requirements_file: %w", err)
}
return many, nil
}
func mcdrRequirements(content []byte) []string {
var requirements []string
for line := range strings.SplitSeq(string(content), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") {
continue
}
requirements = append(requirements, line)
}
return requirements
}
func inspectPython(ctx context.Context, p string) ([]catalog.DescriptorModule, error) {
code := `import ast,json,sys
m=ast.parse(open(sys.argv[1],encoding="utf-8").read())
for n in m.body:
if isinstance(n,ast.Assign) and any(isinstance(t,ast.Name) and t.id=="PLUGIN_METADATA" for t in n.targets):
print(json.dumps(ast.literal_eval(n.value)));break
else: raise ValueError("missing static PLUGIN_METADATA")`
data, err := exec.CommandContext(ctx, "python3", "-c", code, p).Output()
if err != nil {
return nil, fmt.Errorf("inspect Python plugin metadata: %w", err)
}
return nativeMCDR(nil, data, "")
}
func ModuleProvides(m catalog.DescriptorModule, id string) bool {
if m.Ecosystem == catalog.EcosystemBukkit {
return strings.EqualFold(m.ID, id) || slices.ContainsFunc(m.Provides, func(p string) bool { return strings.EqualFold(p, id) })
}
return m.ID == id || slices.Contains(m.Provides, id)
}
package artifact
import (
"crypto/sha1"
"fmt"
"io"
"os"
)
// File is an artifact file on disk. It implements upstream.ArtifactFingerprint.
type File struct {
Path string
}
func (f File) SHA1() ([sha1.Size]byte, error) {
file, err := os.Open(f.Path)
if err != nil {
return [sha1.Size]byte{}, fmt.Errorf("open artifact: %w", err)
}
defer file.Close()
hasher := sha1.New()
if _, err := io.Copy(hasher, file); err != nil {
return [sha1.Size]byte{}, fmt.Errorf("hash artifact: %w", err)
}
return [sha1.Size]byte(hasher.Sum(nil)), nil
}
// MurmurHash computes the CurseForge MurmurHash2 fingerprint of the file
// at Path; see MurmurHashBytes.
func (f File) MurmurHash() (uint32, error) {
data, err := os.ReadFile(f.Path)
if err != nil {
return 0, fmt.Errorf("read artifact: %w", err)
}
return MurmurHashBytes(data), nil
}
// MurmurHashBytes computes CurseForge's custom MurmurHash2 fingerprint.
// It strips whitespace bytes (0x09, 0x0A, 0x0D, 0x20) before mixing.
func MurmurHashBytes(data []byte) uint32 {
const multiplex uint32 = 1540483477
normalizedLen := uint32(0)
for _, value := range data {
if !isCurseForgeWhitespace(value) {
normalizedLen++
}
}
hash := uint32(1) ^ normalizedLen
var pending uint32
var pendingBits uint32
for _, value := range data {
if isCurseForgeWhitespace(value) {
continue
}
pending |= uint32(value) << pendingBits
pendingBits += 8
if pendingBits == 32 {
word := pending * multiplex
word = (word ^ word>>24) * multiplex
hash = hash*multiplex ^ word
pending = 0
pendingBits = 0
}
}
if pendingBits > 0 {
hash = (hash ^ pending) * multiplex
}
hash = (hash ^ hash>>13) * multiplex
return hash ^ hash>>15
}
func isCurseForgeWhitespace(b byte) bool {
return b == 0x09 || b == 0x0A || b == 0x0D || b == 0x20
}
package artifact
import (
"archive/zip"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/version"
"gopkg.in/yaml.v3"
)
const bukkitPluginDescriptorPath = "plugin.yml"
type bukkitReader struct{}
type bukkitPluginDescriptor struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Main string `yaml:"main"`
Description string `yaml:"description"`
Author string `yaml:"author"`
Authors []string `yaml:"authors"`
Website string `yaml:"website"`
APIVersion string `yaml:"api-version"`
API []string `yaml:"api"`
Depend []string `yaml:"depend"`
SoftDepend []string `yaml:"softdepend"`
Libraries []string `yaml:"libraries"`
FoliaSupported bool `yaml:"folia-supported"`
PaperPluginLoader string `yaml:"paper-plugin-loader"`
}
func newBukkitReader() Reader { return &bukkitReader{} }
func (r *bukkitReader) Read(
zipRdr *zip.Reader,
filePath string,
) ([]PackageInfo, error) {
for _, f := range zipRdr.File {
if f.Name != bukkitPluginDescriptorPath {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
descriptor := &bukkitPluginDescriptor{}
if err := yaml.NewDecoder(rc).Decode(descriptor); err != nil {
_ = rc.Close()
return nil, err
}
if err := rc.Close(); err != nil {
return nil, err
}
if strings.TrimSpace(descriptor.Name) == "" ||
strings.TrimSpace(descriptor.Version) == "" ||
strings.TrimSpace(descriptor.Main) == "" {
return nil, nil
}
platform := detectBukkitPluginPlatform(descriptor)
info := PackageInfo{
Ref: catalog.VersionedRef{
Ref: catalog.Ref{
Name: input.ToProjectName(descriptor.Name),
Provider: catalog.ProviderUnknown,
},
Ecosystem: platform,
Version: version.Version(strings.TrimSpace(descriptor.Version)),
},
FilePath: filePath,
Compatibility: Compatibility{
FoliaSupported: descriptor.FoliaSupported,
},
Metadata: catalog.Metadata{
Title: strings.TrimSpace(descriptor.Name),
Description: strings.TrimSpace(descriptor.Description),
Authors: bukkitDescriptorAuthors(
descriptor.Author,
descriptor.Authors,
),
URLs: bukkitDescriptorURLs(descriptor.Website),
},
}
if deps := bukkitDescriptorDeps(platform, descriptor); len(deps) > 0 {
info.Dependencies = deps
}
return []PackageInfo{info}, nil
}
return nil, nil
}
func detectBukkitPluginPlatform(
descriptor *bukkitPluginDescriptor,
) catalog.Ecosystem {
if descriptor.PaperPluginLoader != "" || len(descriptor.Libraries) > 0 {
return catalog.EcosystemPaper
}
for _, list := range [][]string{
descriptor.API,
descriptor.Depend,
descriptor.SoftDepend,
} {
for _, entry := range list {
switch strings.ToLower(strings.TrimSpace(entry)) {
case "paper", "folia", "leaves":
return catalog.EcosystemPaper
}
}
}
return catalog.EcosystemBukkit
}
func bukkitDescriptorDeps(
platform catalog.Ecosystem,
descriptor *bukkitPluginDescriptor,
) []Dependency {
deps := make(
[]Dependency,
0,
len(descriptor.Depend)+len(descriptor.SoftDepend),
)
deps = appendBukkitDescriptorDeps(deps, platform, descriptor.Depend, true)
deps = appendBukkitDescriptorDeps(
deps,
platform,
descriptor.SoftDepend,
false,
)
return deps
}
func appendBukkitDescriptorDeps(
deps []Dependency,
platform catalog.Ecosystem,
names []string,
mandatory bool,
) []Dependency {
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
deps = append(
deps, Dependency{
Ref: catalog.VersionedRef{
Ref: catalog.Ref{
Name: input.ToProjectName(name),
Provider: catalog.ProviderUnknown,
}, Ecosystem: platform,
},
Mandatory: mandatory,
},
)
}
return deps
}
func bukkitDescriptorAuthors(author string, authors []string) []catalog.Person {
people := make([]catalog.Person, 0, len(authors)+1)
if trimmed := strings.TrimSpace(author); trimmed != "" {
people = append(people, catalog.Person{Name: trimmed})
}
for _, item := range authors {
if trimmed := strings.TrimSpace(item); trimmed != "" {
people = append(people, catalog.Person{Name: trimmed})
}
}
return people
}
func bukkitDescriptorURLs(website string) []catalog.URL {
website = strings.TrimSpace(website)
if website == "" {
return nil
}
return []catalog.URL{
{
Name: "Website",
Type: catalog.URLHome,
URL: website,
},
}
}
package artifact
import (
"archive/zip"
"bytes"
"encoding/json/v2"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/internal/fileschema"
"github.com/mclucy/lucy/internal/fn"
"github.com/mclucy/lucy/version"
)
type fabricReader struct{}
var _ Reader = (*fabricReader)(nil)
func newFabricReader() Reader { return &fabricReader{} }
var _ = newFabricReader
func (r *fabricReader) Read(
zipRdr *zip.Reader,
filePath string,
) ([]PackageInfo, error) {
for _, f := range zipRdr.File {
if f.Name != "fabric.mod.json" {
continue
}
reader, err := f.Open()
if err != nil {
return nil, err
}
modInfo := &fileschema.FileFabricModIdentifier{}
decodeErr := json.UnmarshalRead(reader, modInfo)
closeErr := reader.Close()
if decodeErr != nil {
return nil, decodeErr
}
if closeErr != nil {
return nil, closeErr
}
info, err := translateFabricArtifact(zipRdr, modInfo, filePath)
if err != nil {
return nil, err
}
return []PackageInfo{info}, nil
}
return nil, nil
}
func translateFabricArtifact(
zipRdr *zip.Reader,
modInfo *fileschema.FileFabricModIdentifier,
filePath string,
) (PackageInfo, error) {
embeddedNames, err := fabricArtifactEmbeddedModNames(zipRdr, modInfo)
if err != nil {
return PackageInfo{}, err
}
dependencies := make(
[]Dependency, 0,
len(modInfo.Depends)+len(modInfo.Recommends)+len(modInfo.Suggests)+
len(modInfo.Breaks)+len(modInfo.Conflicts),
)
for _, group := range []struct {
dependencies map[string]fn.SingleOrSlice[string]
kind catalog.DependencyKind
}{
{modInfo.Depends, catalog.DependencyKindRequired},
{modInfo.Recommends, catalog.DependencyKindRecommended},
{modInfo.Suggests, catalog.DependencyKindSuggested},
{modInfo.Breaks, catalog.DependencyKindIncompatible},
{modInfo.Conflicts, catalog.DependencyKindConflict},
} {
translated, err := translateFabricArtifactDependencyMap(
group.dependencies,
group.kind,
embeddedNames,
)
if err != nil {
return PackageInfo{}, err
}
dependencies = append(dependencies, translated...)
}
return PackageInfo{
Ref: catalog.VersionedRef{
Ref: catalog.Ref{
Name: input.ToProjectName(modInfo.ID),
Provider: catalog.ProviderUnknown,
},
Ecosystem: catalog.EcosystemFabric,
Version: version.Version(modInfo.Version),
},
FilePath: filePath,
Dependencies: dependencies,
Metadata: catalog.Metadata{
Title: modInfo.Name,
Description: modInfo.Description,
License: modInfo.License,
Authors: fabricArtifactAuthors(modInfo.Authors),
URLs: fabricArtifactURLs(modInfo.Contact),
},
}, nil
}
func translateFabricArtifactDependencyMap(
deps map[string]fn.SingleOrSlice[string],
kind catalog.DependencyKind,
embeddedNames map[string]struct{},
) ([]Dependency, error) {
translated := make([]Dependency, 0, len(deps))
for id, ranges := range deps {
name := input.ToProjectName(id)
_, embedded := embeddedNames[string(name)]
constraint, err := parseFabricArtifactVersionRanges(ranges)
if err != nil {
return nil, err
}
dep := Dependency{
Ref: catalog.VersionedRef{
Ref: catalog.Ref{
Name: name,
Provider: catalog.ProviderUnknown,
}, Ecosystem: catalog.EcosystemFabric,
},
Constraint: constraint,
Mandatory: kind == catalog.DependencyKindRequired,
Kind: kind,
Origin: dependencyOriginForEmbedded(embedded),
}
switch id {
case "minecraft", "java", "fabricloader":
dep.Origin = catalog.DependencyServerProvided
}
if kind == catalog.DependencyKindIncompatible || kind == catalog.DependencyKindConflict {
dep.Constraint = dep.Constraint.Negate()
}
translated = append(translated, dep)
}
return translated, nil
}
func parseFabricArtifactVersionRanges(
ranges fn.SingleOrSlice[string],
) (version.Expr, error) {
return version.ParseConstraints(ranges, version.DialectFabric)
}
func fabricArtifactEmbeddedModNames(
zipRdr *zip.Reader,
modInfo *fileschema.FileFabricModIdentifier,
) (map[string]struct{}, error) {
depNames := make([]string, 0, len(modInfo.Depends))
for id := range modInfo.Depends {
depNames = append(depNames, id)
}
names := make(map[string]struct{}, len(modInfo.Jars))
for _, jar := range modInfo.Jars {
nestedInfo, err := fabricNestedModInfo(zipRdr, jar.File)
if err != nil {
return nil, err
}
if nestedInfo != nil {
fabricRecordEmbeddedName(names, nestedInfo.ID)
for _, provided := range nestedInfo.Provides {
fabricRecordEmbeddedName(names, provided)
}
continue
}
base := jar.File
if _, tail, ok := strings.CutLast(base, "/"); ok {
base = tail
}
base = strings.TrimSuffix(base, ".jar")
for _, dep := range depNames {
if base == dep || strings.HasPrefix(base, dep+"-") {
names[dep] = struct{}{}
break
}
}
}
return names, nil
}
func fabricNestedModInfo(
zipRdr *zip.Reader,
filePath string,
) (*fileschema.FileFabricModIdentifier, error) {
raw, err := readZipEntryBytes(zipRdr, filePath)
if err != nil || raw == nil {
return nil, err
}
nestedZip, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
return nil, err
}
for _, f := range nestedZip.File {
if f.Name != "fabric.mod.json" {
continue
}
reader, err := f.Open()
if err != nil {
return nil, err
}
modInfo := &fileschema.FileFabricModIdentifier{}
decodeErr := json.UnmarshalRead(reader, modInfo)
closeErr := reader.Close()
if decodeErr != nil {
return nil, decodeErr
}
if closeErr != nil {
return nil, closeErr
}
return modInfo, nil
}
return nil, nil
}
func fabricRecordEmbeddedName(names map[string]struct{}, id string) {
if id == "" {
return
}
names[string(input.ToProjectName(id))] = struct{}{}
}
func fabricArtifactAuthors(authors []fileschema.FabricAuthor) []catalog.Person {
translated := make([]catalog.Person, len(authors))
for i, author := range authors {
translated[i] = catalog.Person{Name: string(author)}
}
return translated
}
func fabricArtifactURLs(contact map[string]string) []catalog.URL {
urlSpecs := []struct {
key string
name string
urlType catalog.URLType
}{
{key: "homepage", name: "Homepage", urlType: catalog.URLHome},
{key: "sources", name: "Source", urlType: catalog.URLSource},
{key: "issues", name: "Issues", urlType: catalog.URLIssues},
}
urls := make([]catalog.URL, 0, len(urlSpecs))
for _, spec := range urlSpecs {
url := contact[spec.key]
if url == "" {
continue
}
urls = append(
urls, catalog.URL{
Name: spec.name,
Type: spec.urlType,
URL: url,
},
)
}
return urls
}
package artifact
import (
"archive/zip"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/internal/fileschema"
"github.com/mclucy/lucy/internal/fsutil"
versionpkg "github.com/mclucy/lucy/version"
"github.com/pelletier/go-toml"
)
type forgeReader struct{}
var _ = newForgeReader
func newForgeReader() Reader { return &forgeReader{} }
func (r *forgeReader) Read(
zipRdr *zip.Reader,
filePath string,
) ([]PackageInfo, error) {
_ = r
neoforgeLayout := false
for _, file := range zipRdr.File {
if file.Name == neoforgeModsTomlPath {
neoforgeLayout = true
break
}
}
for _, file := range zipRdr.File {
if file.Name != "META-INF/mods.toml" {
continue
}
return readForgeModsToml(zipRdr, file, filePath, neoforgeLayout)
}
return nil, nil
}
func readForgeModsToml(
zipRdr *zip.Reader,
file *zip.File,
filePath string,
neoforgeLayout bool,
) ([]PackageInfo, error) {
reader, err := file.Open()
if err != nil {
return nil, err
}
defer reader.Close()
data, err := fsutil.CopyBytes(reader, fsutil.MaxZipEntryBytes)
if err != nil {
return nil, err
}
var modIdentifier fileschema.FileModLoaderIdentifier
if err := toml.Unmarshal(data, &modIdentifier); err != nil {
return nil, err
}
// Forge and legacy NeoForge share META-INF/mods.toml. Forge docs identify the
// loader with modId="forge"; legacy NeoForge uses modId="neoforge" instead.
// Modern NeoForge ships META-INF/neoforge.mods.toml beside the shared file,
// so that file alone decides the loader unless the shared file names Forge.
// Docs: https://docs.minecraftforge.net/en/1.21.x/gettingstarted/modfiles/
// Docs: https://docs.neoforged.net/docs/1.20.4/gettingstarted/modfiles/
if !hasLoaderDependency(modIdentifier, "forge") &&
(neoforgeLayout || isNeoforgeModIdentifier(modIdentifier)) {
return nil, nil
}
infos := make([]PackageInfo, 0, len(modIdentifier.Mods))
for _, mod := range modIdentifier.Mods {
if mod.ModID == "forge" {
continue
}
version := versionpkg.Version(mod.Version)
if version == "${file.jarVersion}" {
version = forgeJarVersion(zipRdr)
}
dependencies, err := forgeDependencies(modIdentifier, mod.ModID)
if err != nil {
return nil, err
}
infos = append(
infos, PackageInfo{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: catalog.Name(mod.ModID), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemForge, Version: version},
FilePath: filePath,
Dependencies: dependencies,
Metadata: catalog.Metadata{
Title: mod.DisplayName,
Description: mod.Description,
Authors: []catalog.Person{{Name: mod.Authors}},
License: modIdentifier.License,
URLs: []catalog.URL{
{
Name: "URL",
Type: catalog.URLHome,
URL: mod.DisplayURL,
},
{
Name: "Issue Tracker",
Type: catalog.URLIssues,
URL: modIdentifier.IssueTrackerURL,
},
},
},
},
)
}
return infos, nil
}
func forgeDependencies(
modIdentifier fileschema.FileModLoaderIdentifier,
modID string,
) ([]Dependency, error) {
deps := modIdentifier.Dependencies[modID]
translated := make([]Dependency, 0, len(deps))
for _, dep := range deps {
constraint, err := forgeVersionRange(dep.VersionRange)
if err != nil {
return nil, err
}
translated = append(
translated, Dependency{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: catalog.Name(dep.ModID), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemForge},
Constraint: constraint,
Mandatory: dep.Mandatory,
},
)
}
return translated, nil
}
func forgeVersionRange(versionRange string) (versionpkg.Expr, error) {
return versionpkg.ParseConstraint(versionRange, versionpkg.DialectMaven)
}
func forgeJarVersion(zipRdr *zip.Reader) versionpkg.Version {
for _, file := range zipRdr.File {
if file.Name != "META-INF/MANIFEST.MF" {
continue
}
reader, err := file.Open()
if err != nil {
return versionpkg.Unknown
}
data, err := fsutil.CopyBytes(reader, fsutil.MaxZipEntryBytes)
closeErr := reader.Close()
if err != nil {
return versionpkg.Unknown
}
if closeErr != nil {
return versionpkg.Unknown
}
return forgeManifestVersion(string(data))
}
return versionpkg.Unknown
}
func forgeManifestVersion(manifest string) versionpkg.Version {
const versionField = "Implementation-Version: "
version, found := strings.CutPrefix(manifest, versionField)
if !found {
_, version, found = strings.Cut(manifest, "\n"+versionField)
}
if !found {
return versionpkg.Unknown
}
version = strings.Split(version, "\r")[0]
version = strings.Split(version, "\n")[0]
return versionpkg.Version(version)
}
package artifact
import (
"archive/zip"
"encoding/json/v2"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/internal/fileschema"
"github.com/mclucy/lucy/version"
)
type forgeLegacyReader struct{}
func newForgeLegacyReader() Reader {
return &forgeLegacyReader{}
}
func (r *forgeLegacyReader) Read(
zipRdr *zip.Reader,
filePath string,
) ([]PackageInfo, error) {
for _, f := range zipRdr.File {
if f.Name != "mcmod.info" {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
var mods fileschema.FileForgeModIdentifierOld
decodeErr := json.UnmarshalRead(rc, &mods)
closeErr := rc.Close()
if decodeErr != nil {
return nil, decodeErr
}
if closeErr != nil {
return nil, closeErr
}
infos := make([]PackageInfo, 0, len(mods))
for _, m := range mods {
if m.ModId == "forge" || m.ModId == "minecraft" || m.ModId == "mcp" {
continue
}
info := PackageInfo{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: input.ToProjectName(m.ModId), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemForge, Version: version.Version(m.Version)},
FilePath: filePath,
}
if len(m.Dependencies) > 0 {
deps := make([]Dependency, 0, len(m.Dependencies))
for _, rawDep := range m.Dependencies {
depStr, ok := rawDep.(string)
if !ok || depStr == "" {
continue
}
deps = append(
deps, Dependency{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: input.ToProjectName(depStr), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemForge},
Mandatory: true,
},
)
}
if len(deps) > 0 {
info.Dependencies = deps
}
}
infos = append(infos, info)
}
return infos, nil
}
return nil, nil
}
package artifact
import (
"archive/zip"
"encoding/json/v2"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/internal/fileschema"
"github.com/mclucy/lucy/version"
)
type mcdrReader struct{}
func newMcdrReader() Reader {
return &mcdrReader{}
}
func (r *mcdrReader) Read(
zipRdr *zip.Reader,
filePath string,
) ([]PackageInfo, error) {
for _, f := range zipRdr.File {
if f.Name != "mcdreforged.plugin.json" {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
pluginInfo := &fileschema.FileMcdrPluginIdentifier{}
decodeErr := json.UnmarshalRead(rc, pluginInfo)
closeErr := rc.Close()
if decodeErr != nil {
return nil, decodeErr
}
if closeErr != nil {
return nil, closeErr
}
authors := make([]catalog.Person, len(pluginInfo.Author))
for i, author := range pluginInfo.Author {
authors[i] = catalog.Person{Name: author}
}
urls := make([]catalog.URL, 0, 1)
if pluginInfo.Link != "" {
urls = append(
urls, catalog.URL{
Name: "Link",
Type: catalog.URLSource,
URL: pluginInfo.Link,
},
)
}
info := PackageInfo{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: input.ToProjectName(pluginInfo.ID), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemMCDR, Version: version.Version(pluginInfo.Version)},
FilePath: filePath,
Metadata: catalog.Metadata{
Title: pluginInfo.Name,
Description: pluginInfo.Description.EnUs,
Authors: authors,
URLs: urls,
},
}
if len(pluginInfo.Dependencies) > 0 {
deps := make([]Dependency, 0, len(pluginInfo.Dependencies))
for key, value := range pluginInfo.Dependencies {
constraint, err := version.ParseConstraint(
value,
version.DialectMCDR,
)
if err != nil {
return nil, err
}
deps = append(
deps, Dependency{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: input.ToProjectName(key), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemMCDR},
Constraint: constraint,
Mandatory: true,
},
)
}
info.Dependencies = deps
}
return []PackageInfo{info}, nil
}
return nil, nil
}
package artifact
import (
"archive/zip"
"bytes"
"encoding/json/v2"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/internal/fileschema"
"github.com/mclucy/lucy/internal/fsutil"
versionpkg "github.com/mclucy/lucy/version"
"github.com/pelletier/go-toml"
)
const (
neoforgeModsTomlPath = "META-INF/neoforge.mods.toml"
legacyNeoforgeModsTomlPath = "META-INF/mods.toml"
)
type neoforgeReader struct{}
var _ = newNeoforgeReader
func newNeoforgeReader() Reader {
return &neoforgeReader{}
}
func (r *neoforgeReader) Read(
zipRdr *zip.Reader,
filePath string,
) ([]PackageInfo, error) {
raw, err := readNeoforgeModsToml(zipRdr)
if err != nil {
return nil, err
}
if raw == nil {
return nil, nil
}
var modIdentifier fileschema.FileModLoaderIdentifier
if err := toml.Unmarshal(raw, &modIdentifier); err != nil {
return nil, err
}
jarjarMeta := readNeoforgeJarjarMeta(zipRdr)
embeddedModIds := neoforgeJarjarEmbeddedModIds(zipRdr, jarjarMeta)
embeddedDeps, err := neoforgeJarjarEmbeddedDeps(jarjarMeta)
if err != nil {
return nil, err
}
infos := make([]PackageInfo, 0, len(modIdentifier.Mods))
for _, mod := range modIdentifier.Mods {
if mod.ModID == "neoforge" {
continue
}
version := versionpkg.Version(mod.Version)
if version == "${file.jarVersion}" {
version = readNeoforgeManifestVersion(zipRdr)
}
info := PackageInfo{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: input.ToProjectName(mod.ModID), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemNeoForge, Version: version},
FilePath: filePath,
Metadata: catalog.Metadata{
Title: mod.DisplayName,
Description: mod.Description,
Authors: []catalog.Person{{Name: mod.Authors}},
License: modIdentifier.License,
URLs: []catalog.URL{
{
Name: "URL",
Type: catalog.URLHome,
URL: mod.DisplayURL,
},
{
Name: "Issue Tracker",
Type: catalog.URLIssues,
URL: modIdentifier.IssueTrackerURL,
},
},
},
}
deps := modIdentifier.Dependencies[mod.ModID]
info.Dependencies = make([]Dependency, 0, len(deps)+len(embeddedDeps))
for _, dep := range deps {
if dep.Type == "incompatible" {
continue
}
if strings.EqualFold(dep.Side, "CLIENT") {
continue
}
switch dep.ModID {
case "neoforge", "forge", "minecraft", "java":
continue
}
constraint, err := parseNeoforgeMavenVersionRange(dep.VersionRange)
if err != nil {
return nil, err
}
info.Dependencies = append(
info.Dependencies, Dependency{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: input.ToProjectName(dep.ModID), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemNeoForge},
Constraint: constraint,
Mandatory: dep.Type == "required" || dep.Mandatory,
Origin: dependencyOriginForEmbedded(embeddedModIds[dep.ModID]),
},
)
}
info.Dependencies = append(info.Dependencies, embeddedDeps...)
infos = append(infos, info)
}
return infos, nil
}
func readNeoforgeModsToml(zipRdr *zip.Reader) ([]byte, error) {
// NeoForge 1.20.5+ uses META-INF/neoforge.mods.toml; earlier 1.20.3-1.20.4
// used META-INF/mods.toml before the rename.
// Docs: https://docs.neoforged.net/docs/gettingstarted/modfiles/
// Docs: https://docs.neoforged.net/docs/1.20.4/gettingstarted/modfiles/
// Rename note: https://neoforged.net/news/20.5release/
raw, err := readZipEntryBytes(zipRdr, neoforgeModsTomlPath)
if err != nil || raw != nil {
return raw, err
}
raw, err = readZipEntryBytes(zipRdr, legacyNeoforgeModsTomlPath)
if err != nil || raw == nil {
return raw, err
}
var modIdentifier fileschema.FileModLoaderIdentifier
if err := toml.Unmarshal(raw, &modIdentifier); err != nil {
return nil, err
}
if !isNeoforgeModIdentifier(modIdentifier) {
return nil, nil
}
return raw, nil
}
func isNeoforgeModIdentifier(modIdentifier fileschema.FileModLoaderIdentifier) bool {
// Legacy NeoForge and Forge both use mods.toml, so loader identity comes from
// the dependency target: NeoForge docs use modId="neoforge".
// Docs: https://docs.neoforged.net/docs/1.20.4/gettingstarted/modfiles/
return hasLoaderDependency(modIdentifier, "neoforge")
}
func hasLoaderDependency(
modIdentifier fileschema.FileModLoaderIdentifier,
modID string,
) bool {
for _, deps := range modIdentifier.Dependencies {
for _, dep := range deps {
if dep.ModID == modID {
return true
}
}
}
return false
}
func readNeoforgeManifestVersion(zipRdr *zip.Reader) versionpkg.Version {
raw, err := readZipEntryBytes(zipRdr, "META-INF/MANIFEST.MF")
if err != nil || raw == nil {
return versionpkg.Unknown
}
manifest := string(raw)
const versionField = "Implementation-Version: "
_, version, ok := strings.Cut(manifest, versionField)
if !ok {
return versionpkg.Unknown
}
version = strings.Split(version, "\r")[0]
version = strings.Split(version, "\n")[0]
return versionpkg.Version(version)
}
func readNeoforgeJarjarMeta(zipRdr *zip.Reader) *fileschema.FileNeoforgeJarjar {
raw, err := readZipEntryBytes(zipRdr, "META-INF/jarjar/metadata.json")
if err != nil || raw == nil {
return nil
}
var meta fileschema.FileNeoforgeJarjar
if err := json.Unmarshal(raw, &meta); err != nil {
return nil
}
return &meta
}
func neoforgeJarjarEmbeddedModIds(
zipRdr *zip.Reader,
meta *fileschema.FileNeoforgeJarjar,
) map[string]bool {
if meta == nil {
return nil
}
byName := make(map[string]*zip.File, len(zipRdr.File))
for _, f := range zipRdr.File {
byName[f.Name] = f
}
modIds := make(map[string]bool)
for _, entry := range meta.Jars {
f, ok := byName[entry.Path]
if !ok {
continue
}
rc, err := f.Open()
if err != nil {
continue
}
jarBytes, err := fsutil.CopyBytes(rc, fsutil.MaxZipEntryBytes)
_ = rc.Close()
if err != nil {
continue
}
nestedZip, err := zip.NewReader(
bytes.NewReader(jarBytes),
int64(len(jarBytes)),
)
if err != nil {
continue
}
raw, err := readNeoforgeModsToml(nestedZip)
if err != nil || raw == nil {
continue
}
var inner fileschema.FileModLoaderIdentifier
if err := toml.Unmarshal(raw, &inner); err != nil {
continue
}
for _, mod := range inner.Mods {
if mod.ModID != "" {
modIds[mod.ModID] = true
}
}
}
return modIds
}
func neoforgeJarjarEmbeddedDeps(meta *fileschema.FileNeoforgeJarjar) ([]Dependency, error) {
if meta == nil {
return nil, nil
}
deps := make([]Dependency, 0, len(meta.Jars))
for _, entry := range meta.Jars {
constraint, err := parseNeoforgeMavenVersionRange(entry.Version.Range)
if err != nil {
return nil, err
}
deps = append(
deps, Dependency{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: input.ToProjectName(entry.Identifier.Group + ":" + entry.Identifier.Artifact), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemUnspecified},
Constraint: constraint,
Mandatory: true,
Origin: catalog.DependencyEmbedded,
},
)
}
return deps, nil
}
func parseNeoforgeMavenVersionRange(interval string) (versionpkg.Expr, error) {
return versionpkg.ParseConstraint(interval, versionpkg.DialectMaven)
}
package artifact
import (
"archive/zip"
"encoding/json/v2"
"errors"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/internal/fileschema"
"github.com/mclucy/lucy/internal/fn"
"github.com/mclucy/lucy/version"
)
const spongePluginMetadataPath = "META-INF/sponge_plugins.json"
type spongeReader struct{}
func newSpongeReader() Reader {
return &spongeReader{}
}
func (r *spongeReader) Read(
zipRdr *zip.Reader,
filePath string,
) ([]PackageInfo, error) {
for _, f := range zipRdr.File {
if f.Name != spongePluginMetadataPath {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
var metadata fileschema.FileSpongePluginsIdentifier
decodeErr := json.UnmarshalRead(rc, &metadata)
if err := errors.Join(decodeErr, rc.Close()); err != nil {
return nil, err
}
if !validSpongeMetadata(&metadata) {
return []PackageInfo{}, nil
}
infos := make([]PackageInfo, 0, len(metadata.Plugins))
for _, plugin := range metadata.Plugins {
info, ok, err := translateSpongePlugin(&metadata, plugin, filePath)
if err != nil {
return nil, err
}
if !ok {
continue
}
infos = append(infos, info)
}
if len(infos) == 0 {
return []PackageInfo{}, nil
}
return infos, nil
}
return nil, nil
}
func validSpongeMetadata(metadata *fileschema.FileSpongePluginsIdentifier) bool {
if strings.TrimSpace(metadata.Loader.Name) == "" ||
strings.TrimSpace(metadata.Loader.Version) == "" ||
len(metadata.Plugins) == 0 {
return false
}
for _, plugin := range metadata.Plugins {
if hasConcreteSpongePluginIdentity(metadata, plugin) {
return true
}
}
return false
}
func hasConcreteSpongePluginIdentity(
metadata *fileschema.FileSpongePluginsIdentifier,
plugin fileschema.FileSpongePluginMetadata,
) bool {
if strings.TrimSpace(plugin.ID) == "" || strings.TrimSpace(plugin.Entrypoint) == "" {
return false
}
return strings.TrimSpace(resolveSpongePluginVersion(metadata, plugin)) != ""
}
func translateSpongePlugin(
metadata *fileschema.FileSpongePluginsIdentifier,
plugin fileschema.FileSpongePluginMetadata,
localPath string,
) (PackageInfo, bool, error) {
if !hasConcreteSpongePluginIdentity(metadata, plugin) {
return PackageInfo{}, false, nil
}
v := resolveSpongePluginVersion(metadata, plugin)
info := PackageInfo{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: input.ToProjectName(plugin.ID), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemSponge, Version: version.Version(v)},
FilePath: localPath,
Metadata: catalog.Metadata{
Title: fn.Ternary(plugin.Name != "", plugin.Name, plugin.ID),
Description: plugin.Description,
License: metadata.License,
Authors: translateSpongeContributors(
resolveSpongePluginContributors(metadata, plugin),
),
URLs: translateSpongeLinks(
resolveSpongePluginLinks(metadata, plugin),
),
},
}
deps, err := translateSpongeDependencies(
resolveSpongePluginDependencies(metadata, plugin),
)
if err != nil {
return PackageInfo{}, false, err
}
if len(deps) > 0 {
info.Dependencies = deps
}
return info, true, nil
}
func resolveSpongePluginVersion(
metadata *fileschema.FileSpongePluginsIdentifier,
plugin fileschema.FileSpongePluginMetadata,
) string {
if v := strings.TrimSpace(plugin.Version); v != "" {
return v
}
return strings.TrimSpace(metadata.Global.Version)
}
func resolveSpongePluginLinks(
metadata *fileschema.FileSpongePluginsIdentifier,
plugin fileschema.FileSpongePluginMetadata,
) struct {
Homepage string
Source string
Issues string
} {
links := struct {
Homepage string
Source string
Issues string
}{
Homepage: metadata.Global.Links.Homepage,
Source: metadata.Global.Links.Source,
Issues: metadata.Global.Links.Issues,
}
if strings.TrimSpace(plugin.Links.Homepage) != "" {
links.Homepage = plugin.Links.Homepage
}
if strings.TrimSpace(plugin.Links.Source) != "" {
links.Source = plugin.Links.Source
}
if strings.TrimSpace(plugin.Links.Issues) != "" {
links.Issues = plugin.Links.Issues
}
return links
}
func resolveSpongePluginContributors(
metadata *fileschema.FileSpongePluginsIdentifier,
plugin fileschema.FileSpongePluginMetadata,
) []struct {
Name string
Description string
} {
contributors := metadata.Global.Contributors
if len(plugin.Contributors) > 0 {
contributors = plugin.Contributors
}
resolved := make(
[]struct {
Name string
Description string
}, 0, len(contributors),
)
for _, contributor := range contributors {
resolved = append(
resolved, struct {
Name string
Description string
}{
Name: contributor.Name,
Description: contributor.Description,
},
)
}
return resolved
}
func resolveSpongePluginDependencies(
metadata *fileschema.FileSpongePluginsIdentifier,
plugin fileschema.FileSpongePluginMetadata,
) []struct {
ID string
Version string
LoadOrder string
Optional bool
} {
deps := metadata.Global.Dependencies
if len(plugin.Dependencies) > 0 {
deps = plugin.Dependencies
}
resolved := make(
[]struct {
ID string
Version string
LoadOrder string
Optional bool
}, 0, len(deps),
)
for _, dep := range deps {
resolved = append(
resolved, struct {
ID string
Version string
LoadOrder string
Optional bool
}{
ID: dep.ID,
Version: dep.Version,
LoadOrder: dep.LoadOrder,
Optional: dep.Optional,
},
)
}
return resolved
}
func translateSpongeContributors(
contributors []struct {
Name string
Description string
},
) []catalog.Person {
people := make([]catalog.Person, 0, len(contributors))
for _, contributor := range contributors {
if strings.TrimSpace(contributor.Name) == "" {
continue
}
people = append(
people, catalog.Person{
Name: contributor.Name,
Role: contributor.Description,
},
)
}
return people
}
func translateSpongeLinks(
links struct {
Homepage string
Source string
Issues string
},
) []catalog.URL {
urls := make([]catalog.URL, 0, 3)
if homepage := strings.TrimSpace(links.Homepage); homepage != "" {
urls = append(
urls,
catalog.URL{Name: "Homepage", Type: catalog.URLHome, URL: homepage},
)
}
if source := strings.TrimSpace(links.Source); source != "" {
urls = append(
urls,
catalog.URL{Name: "Source", Type: catalog.URLSource, URL: source},
)
}
if issues := strings.TrimSpace(links.Issues); issues != "" {
urls = append(
urls,
catalog.URL{Name: "Issues", Type: catalog.URLIssues, URL: issues},
)
}
return urls
}
func translateSpongeDependencies(
deps []struct {
ID string
Version string
LoadOrder string
Optional bool
},
) ([]Dependency, error) {
translated := make([]Dependency, 0, len(deps))
for _, dep := range deps {
id := strings.TrimSpace(dep.ID)
v := strings.TrimSpace(dep.Version)
if id == "" || v == "" || strings.EqualFold(id, "spongeapi") {
continue
}
constraint, err := version.ParseConstraint(v, version.DialectMaven)
if err != nil {
return nil, err
}
translated = append(
translated, Dependency{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: input.ToProjectName(id), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemSponge},
Constraint: constraint,
Mandatory: !dep.Optional,
},
)
}
return translated, nil
}
package artifact
import (
"archive/zip"
"encoding/json/v2"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/version"
"gopkg.in/yaml.v3"
)
type velocityReader struct{}
type bungeeCordReader struct{}
type velocityPluginDescriptor struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Authors []string `json:"authors"`
URL string `json:"url"`
}
type bungeeCordPluginDescriptor struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Description string `yaml:"description"`
Author string `yaml:"author"`
Authors []string `yaml:"authors"`
Website string `yaml:"website"`
}
func newVelocityReader() Reader {
return &velocityReader{}
}
func newBungeeCordReader() Reader {
return &bungeeCordReader{}
}
func (r *velocityReader) Read(
zipRdr *zip.Reader,
filePath string,
) ([]PackageInfo, error) {
for _, f := range zipRdr.File {
if f.Name != "velocity-plugin.json" {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
descriptor := &velocityPluginDescriptor{}
decodeErr := json.UnmarshalRead(rc, descriptor)
closeErr := rc.Close()
if decodeErr != nil {
return nil, decodeErr
}
if closeErr != nil {
return nil, closeErr
}
authors := make([]catalog.Person, 0, len(descriptor.Authors))
for _, author := range descriptor.Authors {
authors = append(authors, catalog.Person{Name: author})
}
urls := make([]catalog.URL, 0, 1)
if descriptor.URL != "" {
urls = append(
urls, catalog.URL{
Name: "Homepage",
Type: catalog.URLHome,
URL: descriptor.URL,
},
)
}
return []PackageInfo{
{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: input.ToProjectName(descriptor.ID), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemVelocity, Version: version.Version(descriptor.Version)},
FilePath: filePath,
Metadata: catalog.Metadata{
Title: descriptor.Name,
Description: descriptor.Description,
Authors: authors,
URLs: urls,
},
},
}, nil
}
return nil, nil
}
func (r *bungeeCordReader) Read(
zipRdr *zip.Reader,
filePath string,
) ([]PackageInfo, error) {
for _, f := range zipRdr.File {
if f.Name != "bungee.yml" {
continue
}
rc, err := f.Open()
if err != nil {
return nil, err
}
descriptor := &bungeeCordPluginDescriptor{}
if err := yaml.NewDecoder(rc).Decode(descriptor); err != nil {
_ = rc.Close()
return nil, err
}
if err := rc.Close(); err != nil {
return nil, err
}
authors := make([]catalog.Person, 0, len(descriptor.Authors)+1)
if descriptor.Author != "" {
authors = append(authors, catalog.Person{Name: descriptor.Author})
}
for _, author := range descriptor.Authors {
authors = append(authors, catalog.Person{Name: author})
}
urls := make([]catalog.URL, 0, 1)
if descriptor.Website != "" {
urls = append(
urls, catalog.URL{
Name: "Website",
Type: catalog.URLHome,
URL: descriptor.Website,
},
)
}
return []PackageInfo{
{
Ref: catalog.VersionedRef{Ref: catalog.Ref{Name: input.ToProjectName(descriptor.Name), Provider: catalog.ProviderUnknown}, Ecosystem: catalog.EcosystemBungeeCord, Version: version.Version(descriptor.Version)},
FilePath: filePath,
Metadata: catalog.Metadata{
Title: descriptor.Name,
Description: descriptor.Description,
Authors: authors,
URLs: urls,
},
},
}, nil
}
return nil, nil
}
// Package artifact provides types and interfaces for extracting metadata from
// package artifact files (JAR, ZIP, PYZ, MCDR plugin archives).
package artifact
import (
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/version"
)
// Dependency represents a platform-qualified dependency detected from an
// artifact file. Artifact metadata does not establish upstream provenance.
type Dependency struct {
Ref catalog.VersionedRef
Constraint version.Expr
Mandatory bool
Kind catalog.DependencyKind
Origin catalog.DependencyOrigin
}
type Compatibility struct {
FoliaSupported bool
}
// PackageInfo represents metadata extracted from a single artifact file
// (JAR/ZIP/PYZ/MCDR). Ref carries both the observed ecosystem and version.
type PackageInfo struct {
Ref catalog.VersionedRef
FilePath string
Dependencies []Dependency
Metadata catalog.Metadata
Compatibility Compatibility
}
func dependencyOriginForEmbedded(embedded bool) catalog.DependencyOrigin {
if embedded {
return catalog.DependencyEmbedded
}
return catalog.DependencyRegular
}
package artifact
import (
"archive/zip"
"github.com/mclucy/lucy/internal/fsutil"
)
// readZipEntryBytes returns the contents of a named ZIP entry, or nil if missing.
func readZipEntryBytes(zipRdr *zip.Reader, name string) ([]byte, error) {
data, _, err := fsutil.ReadZipEntry(
zipRdr,
name,
fsutil.MaxZipEntryBytes,
)
return data, err
}
// Package artifactstore owns the durable per-user store for installed
// artifacts. It is separate from the network cache: entries survive cache
// expiration and clearing, and each registered workspace's lock retains the
// objects its installation pins.
package artifactstore
import (
"fmt"
"path/filepath"
"github.com/mclucy/lucy/cas"
"github.com/mclucy/lucy/serverregistry"
)
// DirectoryName is the artifact store's directory inside Lucy's per-user data
// root.
const DirectoryName = "artifacts"
// Directory reports the per-user artifact store directory without creating it.
func Directory() (string, error) {
root, err := serverregistry.DataRoot()
if err != nil {
return "", err
}
return filepath.Join(root, DirectoryName), nil
}
// Open opens the per-user store, creating its private namespaces when absent.
func Open() (*Store, error) {
directory, err := Directory()
if err != nil {
return nil, fmt.Errorf("locate artifact store: %w", err)
}
return OpenAt(directory)
}
// OpenAt opens a store at an explicit directory. It creates the same private
// object and staging namespaces as the per-user store.
func OpenAt(directory string) (*Store, error) {
store, err := cas.New(directory)
if err != nil {
return nil, fmt.Errorf("open artifact store: %w", err)
}
return &Store{Store: store, directory: directory}, nil
}
// Store is the artifact store's content-addressed storage. Object names never
// carry a consumer's filename or extension; consumers give meaning to a digest
// through their locks.
type Store struct {
*cas.Store
directory string
}
package artifactstore
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/mclucy/lucy/cas"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/internal/workspacewrite"
"github.com/mclucy/lucy/serverregistry"
)
// Report describes one garbage collection. Bytes counts the reclaimed object
// contents, not staging files cleaned separately.
type Report struct {
Removed int `json:"removed"`
Bytes int64 `json:"bytes"`
}
// GC removes objects that no registered server's lock pins. It refuses to
// delete anything when an operation lease is active, when the registry or any
// registered workspace cannot report a complete reference set, when another
// Lucy operation owns a registered workspace, or when a lock names an artifact
// without a usable sha256 digest.
//
// Locks are acquired in one order everywhere: operation gate, workspace writer
// locks, registry mutation lock, then the content store lock. An operation
// creates its lease before taking its workspace lock. A sweep holds the
// operation gate from the lease check through the last removal, so no
// acquisition can start while references are read or objects are deleted.
func (s *Store) GC(registry *serverregistry.Registry) (Report, error) {
if registry == nil {
return Report{}, errors.New("server registry is unavailable")
}
gate, err := s.beginCollection()
if err != nil {
return Report{}, err
}
defer func() { _ = gate.Close() }()
entries, err := registry.List()
if err != nil {
return Report{}, fmt.Errorf("inspect server registry: %w", err)
}
validated, err := validatedEntries(entries)
if err != nil {
return Report{}, err
}
workspaceLocks, err := lockWorkspaces(validated)
if err != nil {
return Report{}, err
}
defer func() {
for i := len(workspaceLocks) - 1; i >= 0; i-- {
_ = workspaceLocks[i].Close()
}
}()
registryLock, err := registry.Lock()
if err != nil {
return Report{}, fmt.Errorf("lock server registry: %w", err)
}
defer func() { _ = registryLock.Close() }()
refreshed, err := registry.List()
if err != nil {
return Report{}, fmt.Errorf("refresh server registry: %w", err)
}
if !sameRegistrySet(validated, refreshed) {
return Report{}, errors.New("registered server set changed during collection; retry")
}
referenced, err := lockReferences(validated)
if err != nil {
return Report{}, err
}
var report Report
removed, bytes, err := s.Store.Sweep(func() (map[cas.Digest]struct{}, func() error, error) {
return referenced, nil, nil
})
report.Removed = removed
report.Bytes = bytes
if err != nil {
return report, fmt.Errorf("collect artifact store: %w", err)
}
if err := s.Cleanup(); err != nil {
return report, fmt.Errorf("clean artifact store staging: %w", err)
}
return report, nil
}
// validatedEntries rejects every state whose references cannot be determined
// and returns one entry per canonical path. Lock-only entries are valid: a
// lock-only installation is still a registered server whose lock pins
// artifacts.
func validatedEntries(entries []serverregistry.Entry) ([]serverregistry.Entry, error) {
validated := make([]serverregistry.Entry, 0, len(entries))
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
switch entry.State {
case serverregistry.StateAvailable, serverregistry.StateLockOnly:
default:
problem := entry.Problem
if problem == "" {
problem = "the workspace cannot be inspected"
}
if entry.State == serverregistry.StateMissing {
return nil, fmt.Errorf(
"registered server %s is missing: %s; if its directory no longer exists, remove the entry with `lucy server forget <path>`",
displayEntry(entry), problem,
)
}
return nil, fmt.Errorf(
"registered server %s is %s: %s",
displayEntry(entry), entry.State, problem,
)
}
if _, duplicate := seen[entry.Path]; duplicate {
continue
}
seen[entry.Path] = struct{}{}
validated = append(validated, entry)
}
sort.Slice(validated, func(i, j int) bool {
return validated[i].Path < validated[j].Path
})
return validated, nil
}
// lockWorkspaces takes every registered workspace's writer lock without
// creating or changing workspace state. An active operation is a reason to
// refuse collection, not a reason to wait: reference state would keep moving
// while it completed.
func lockWorkspaces(entries []serverregistry.Entry) ([]*fsutil.FileLock, error) {
locks := make([]*fsutil.FileLock, 0, len(entries))
fail := func(reason error) ([]*fsutil.FileLock, error) {
for i := len(locks) - 1; i >= 0; i-- {
_ = locks[i].Close()
}
return nil, reason
}
for _, entry := range entries {
lock, err := lockWorkspace(entry.Path)
if err != nil {
return fail(fmt.Errorf(
"registered server %s cannot be held still: %w",
displayEntry(entry), err,
))
}
locks = append(locks, lock)
}
return locks, nil
}
func lockWorkspace(root string) (*fsutil.FileLock, error) {
relative := ".lucy/" + workspacewrite.LockFileName
if err := workspacewrite.SafePath(root, relative); err != nil {
return nil, err
}
file, err := os.OpenFile(filepath.Join(root, filepath.FromSlash(relative)), os.O_RDWR, 0)
if errors.Is(err, os.ErrNotExist) {
return nil, errors.New("workspace writer lock is missing")
}
if err != nil {
return nil, err
}
lock, err := fsutil.LockOpenedFile(file, false)
if errors.Is(err, fsutil.ErrLocked) {
return nil, errors.New("another Lucy operation is active")
}
return lock, err
}
func sameRegistrySet(expected, current []serverregistry.Entry) bool {
currentByPath := make(map[string]string, len(current))
for _, entry := range current {
currentByPath[entry.Path] = entry.State
}
for _, entry := range expected {
if state, ok := currentByPath[entry.Path]; !ok || state != entry.State {
return false
}
}
return len(currentByPath) == len(expected)
}
func displayEntry(entry serverregistry.Entry) string {
if entry.Name != "" {
return fmt.Sprintf("%q (%s)", entry.Name, entry.Path)
}
return entry.Path
}
func lockReferences(entries []serverregistry.Entry) (map[cas.Digest]struct{}, error) {
referenced := make(map[cas.Digest]struct{})
for _, entry := range entries {
journal := filepath.Join(entry.Path, workspacewrite.InstallJournalName)
if _, err := os.Lstat(journal); err == nil {
return nil, fmt.Errorf("registered server %s has an interrupted installation; run `lucy install` in that workspace before collecting", displayEntry(entry))
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("inspect installation journal for registered server %s: %w", displayEntry(entry), err)
}
locked, err := document.LockFile(entry.Path).Read()
if errors.Is(err, os.ErrNotExist) {
// A registered workspace without a lock pins no artifacts.
continue
}
if err != nil {
return nil, fmt.Errorf(
"registered server %s has an unreadable lock: %w",
displayEntry(entry), err,
)
}
if err := addLockReferences(referenced, entry, locked); err != nil {
return nil, err
}
}
return referenced, nil
}
func addLockReferences(
referenced map[cas.Digest]struct{},
entry serverregistry.Entry,
locked *document.Lock,
) error {
owner := displayEntry(entry)
if err := addArtifactReference(referenced, owner, "server core", locked.Server.Artifact); err != nil {
return err
}
for _, input := range locked.Server.Inputs {
if err := addArtifactReference(referenced, owner, "bootstrap input "+input.Path, input.Artifact); err != nil {
return err
}
}
for _, pkg := range locked.Packages {
name := "package " + pkg.ProjectID
if pkg.Version != "" {
name += "@" + pkg.Version.String()
}
if err := addArtifactReference(referenced, owner, name, pkg.Artifact); err != nil {
return err
}
}
return nil
}
func addArtifactReference(
referenced map[cas.Digest]struct{},
owner, name string,
artifact catalog.Artifact,
) error {
if artifact.Embedded != "" {
// Embedded inputs are extracted from their pinned parent object.
return nil
}
value := strings.ToLower(strings.TrimSpace(artifact.Hashes["sha256"]))
if value == "" {
return fmt.Errorf(
"server %s: %s has no sha256 digest; its store object cannot be determined",
owner, name,
)
}
digest, err := cas.ParseDigest("sha256:" + value)
if err != nil {
return fmt.Errorf(
"server %s: %s has an invalid sha256 digest %q: %w",
owner, name, value, err,
)
}
referenced[digest] = struct{}{}
return nil
}
package artifactstore
import (
"crypto/rand"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/mclucy/lucy/internal/fsutil"
)
const (
operationsLockName = "operations.lock"
operationPrefix = "operation-"
operationSuffix = ".lease"
)
// Lease marks one in-flight operation that may acquire artifacts before it has
// registered a workspace. It is global to the per-user store and survives
// process boundaries through a private file lock. Hold it from before the
// first artifact access through registration, installation, or planning so a
// collection sweep cannot reclaim bytes the operation is still using.
type Lease struct {
file *os.File
lock *fsutil.FileLock
name string
closeOnce sync.Once
closeErr error
}
// AcquireOperation acquires a lease on the per-user artifact store.
func AcquireOperation() (*Lease, error) {
store, err := Open()
if err != nil {
return nil, err
}
defer store.Close()
return store.AcquireLease()
}
// AcquireLease creates and locks a unique operation lease. Lease creation is
// serialized by the same gate a collection uses, so a sweep either observes
// the lease before deleting or waits for the lease to exist.
func (s *Store) AcquireLease() (*Lease, error) {
gate, err := fsutil.LockFile(s.tmpPath(operationsLockName))
if err != nil {
return nil, fmt.Errorf("lock artifact store operations: %w", err)
}
defer func() { _ = gate.Close() }()
for {
name := s.tmpPath(operationPrefix + rand.Text() + operationSuffix)
file, err := os.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("create artifact store lease: %w", err)
}
lock, err := fsutil.LockOpenedFile(file, false)
if err != nil {
return nil, errors.Join(
fmt.Errorf("lock artifact store lease: %w", err),
os.Remove(name),
)
}
return &Lease{file: file, lock: lock, name: name}, nil
}
}
// Close releases the lease and removes its file. It is idempotent.
func (l *Lease) Close() error {
if l == nil {
return nil
}
l.closeOnce.Do(func() {
var result error
if l.lock != nil {
result = l.lock.Close()
}
if l.file != nil && l.lock == nil {
result = errors.Join(result, l.file.Close())
}
if err := os.Remove(l.name); err != nil && !errors.Is(err, os.ErrNotExist) {
result = errors.Join(result, err)
}
l.closeErr = result
})
return l.closeErr
}
// beginCollection takes the operation gate and refuses when any lease is
// active. Stale lease files left by a crashed process are removed. The gate
// must stay held through the sweep so no operation can acquire artifacts
// between this check and the last removal.
func (s *Store) beginCollection() (*fsutil.FileLock, error) {
gate, err := fsutil.LockFile(s.tmpPath(operationsLockName))
if err != nil {
return nil, fmt.Errorf("lock artifact store operations: %w", err)
}
entries, err := os.ReadDir(s.tmpPath())
if err != nil {
return nil, errors.Join(err, gate.Close())
}
for _, entry := range entries {
name := entry.Name()
if !strings.HasPrefix(name, operationPrefix) || !strings.HasSuffix(name, operationSuffix) {
continue
}
path := s.tmpPath(name)
if !entry.Type().IsRegular() {
return nil, errors.Join(
fmt.Errorf("artifact store operation lease %s is not a regular file", path),
gate.Close(),
)
}
file, err := os.OpenFile(path, os.O_RDWR, 0)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return nil, errors.Join(err, gate.Close())
}
lock, lockErr := fsutil.LockOpenedFile(file, false)
if errors.Is(lockErr, fsutil.ErrLocked) {
return nil, errors.Join(
errors.New("another Lucy operation is acquiring or using artifacts"),
gate.Close(),
)
}
if lockErr != nil {
return nil, errors.Join(lockErr, gate.Close())
}
closeErr := lock.Close()
removeErr := os.Remove(path)
if closeErr != nil || removeErr != nil {
return nil, errors.Join(closeErr, removeErr, gate.Close())
}
}
return gate, nil
}
func (s *Store) tmpPath(parts ...string) string {
joined := make([]string, 0, len(parts)+2)
joined = append(joined, s.directory, "tmp")
joined = append(joined, parts...)
return filepath.Join(joined...)
}
package bootstrap
import (
"archive/zip"
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json/v2"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/upstream/routing"
)
// CopyLockedFile writes src to dst with mode, replacing dst through a
// temporary file. An interrupted copy never leaves a partial file, and other
// links to the previous dst keep their bytes.
func CopyLockedFile(src, dst string, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.CreateTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".*")
if err != nil {
return err
}
temporary := out.Name()
defer func() {
if out != nil {
out.Close()
os.Remove(temporary)
}
}()
if err := out.Chmod(mode); err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
return err
}
if err := out.Close(); err != nil {
return err
}
out = nil
return os.Rename(temporary, dst)
}
// PrepareLocked builds the server tree of a locked server in stage. java is the
// Java executable for the bootstrap steps that run Java; it may be empty when
// no runtime is installed, and only those steps fail then.
func PrepareLocked(ctx context.Context, stage string, server document.LockedServer, corePath string, inputs map[string]string, java string, output io.Writer) error {
if err := document.SafeRelativePath(server.Directory); err != nil {
return err
}
root := filepath.Join(stage, server.Directory)
if err := os.MkdirAll(root, 0o755); err != nil {
return err
}
var installer *zip.ReadCloser
if server.Bootstrapper == "forge-installer" {
var err error
installer, err = zip.OpenReader(corePath)
if err != nil {
return err
}
defer installer.Close()
}
for _, input := range server.Inputs {
if err := document.SafeRelativePath(input.Path); err != nil {
return err
}
dst := filepath.Join(root, filepath.FromSlash(input.Path))
if input.Embedded != "" {
if installer == nil {
return fmt.Errorf("embedded input requires an installer")
}
if err := extractLockedEntry(&installer.Reader, input.Embedded, dst); err != nil {
return err
}
continue
}
src, ok := inputs[input.Path]
if !ok {
return fmt.Errorf("missing locked bootstrap input %s", input.Path)
}
if err := CopyLockedFile(src, dst, 0o644); err != nil {
return err
}
}
switch server.Bootstrapper {
case "":
return CopyLockedFile(corePath, filepath.Join(root, "server.jar"), 0o644)
case "paperclip":
if err := CopyLockedFile(corePath, filepath.Join(root, "server.jar"), 0o644); err != nil {
return err
}
if err := requireJava(java, "Paperclip patching"); err != nil {
return err
}
cmd := exec.CommandContext(ctx, java, "-Dpaperclip.patchonly=true", "-jar", "server.jar")
cmd.Dir = root
captured, err := runLockedProcess(cmd, output)
if err != nil {
return fmt.Errorf("Paperclip patching: %w\n%s", err, captured)
}
return nil
case "fabric":
p, err := routing.MavenPath("net.fabricmc:fabric-loader:" + server.CoreVersion.String())
if err != nil {
return err
}
if err := CopyLockedFile(corePath, filepath.Join(root, "libraries", p), 0o644); err != nil {
return err
}
classpath := []string{filepath.ToSlash(filepath.Join("libraries", p))}
for _, input := range server.Inputs {
if strings.HasPrefix(input.Path, "libraries/") {
classpath = append(classpath, input.Path)
}
}
unix := strings.Join(classpath, ":")
windows := strings.Join(classpath, ";")
if err := os.WriteFile(filepath.Join(root, "run.sh"), []byte("#!/bin/sh\ncd -- \"$(dirname -- \"$0\")\"\nexec java -cp '"+unix+"' net.fabricmc.loader.impl.launch.server.FabricServerLauncher nogui \"$@\"\n"), 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(root, "run.bat"), []byte("@echo off\r\ncd /d %~dp0\r\njava -cp \""+windows+"\" net.fabricmc.loader.impl.launch.server.FabricServerLauncher nogui %*\r\n"), 0o644); err != nil {
return err
}
if err := writeFabricShimJar(filepath.Join(root, "fabric-server-launch.jar"), classpath); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(root, "fabric-server-launcher.properties"), []byte("serverJar=server.jar\n"), 0o644); err != nil {
return err
}
return nil
case "forge-installer":
return executeServerProfile(ctx, root, server, corePath, &installer.Reader, java, output)
default:
return fmt.Errorf("unsupported bootstrap adapter %q", server.Bootstrapper)
}
}
func extractLockedEntry(z *zip.Reader, name, dst string) error {
for _, f := range z.File {
if f.Name != name {
continue
}
r, err := f.Open()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return errors.Join(err, r.Close())
}
out, err := os.Create(dst)
if err != nil {
return errors.Join(err, r.Close())
}
_, err = io.Copy(out, r)
if err != nil {
return errors.Join(err, r.Close(), out.Close())
}
return errors.Join(r.Close(), out.Close())
}
return fmt.Errorf("missing pinned installer entry %s", name)
}
// runLockedProcess streams a subprocess's combined output to output while
// retaining it for the error message. It returns the captured output.
func runLockedProcess(cmd *exec.Cmd, output io.Writer) ([]byte, error) {
if output == nil {
output = io.Discard
}
var captured bytes.Buffer
writer := io.MultiWriter(output, &captured)
cmd.Stdout = writer
cmd.Stderr = writer
err := cmd.Run()
return captured.Bytes(), err
}
// ErrNoJava reports a bootstrap step that runs Java when no Java runtime is
// installed.
var ErrNoJava = errors.New("no Java runtime is installed")
func requireJava(java, step string) error {
if java == "" {
return fmt.Errorf("%s needs a Java runtime: %w", step, ErrNoJava)
}
return nil
}
func readLockedEntry(z *zip.Reader, name string) ([]byte, error) {
raw, found, err := fsutil.ReadZipEntry(
z,
name,
fsutil.MaxNestedZipEntryBytes,
)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("missing installer metadata %s", name)
}
return raw, nil
}
func executeServerProfile(ctx context.Context, root string, server document.LockedServer, corePath string, z *zip.Reader, java string, output io.Writer) error {
raw, err := readLockedEntry(z, "install_profile.json")
if err != nil {
return err
}
var profile struct {
Data map[string]struct {
Server string `json:"server"`
} `json:"data"`
Processors []struct {
Sides []string `json:"sides"`
Jar string `json:"jar"`
Classpath []string `json:"classpath"`
Args []string `json:"args"`
Outputs map[string]string `json:"outputs"`
} `json:"processors"`
}
if err := json.Unmarshal(raw, &profile); err != nil {
return err
}
vars := map[string]string{
"SIDE": "server",
"ROOT": root,
"LIBRARY_DIR": filepath.Join(root, "libraries"),
"INSTALLER": corePath,
"MINECRAFT_VERSION": server.Minecraft.String(),
"MINECRAFT_JAR": filepath.Join(root, "server.jar"),
}
for key, value := range profile.Data {
v := value.Server
if strings.HasPrefix(v, "/") {
destination := filepath.Join(root, ".bootstrap-data", key)
if err := extractLockedEntry(z, strings.TrimPrefix(v, "/"), destination); err != nil {
return err
}
vars[key] = destination
} else {
vars[key] = v
}
}
var expand func(string) (string, error)
expand = func(value string) (string, error) {
if strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'") {
return strings.Trim(value, "'"), nil
}
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
p, err := routing.MavenPath(value[1 : len(value)-1])
return filepath.Join(root, "libraries", p), err
}
for range 16 {
start := strings.IndexByte(value, '{')
if start < 0 {
return value, nil
}
end := strings.IndexByte(value[start:], '}')
if end < 0 {
return "", fmt.Errorf("invalid processor token %q", value)
}
end += start
key := value[start+1 : end]
replacement, ok := vars[key]
if !ok {
return "", fmt.Errorf("unknown processor variable %s", key)
}
if strings.HasPrefix(replacement, "[") {
var err error
replacement, err = expand(replacement)
if err != nil {
return "", err
}
} else {
replacement = strings.Trim(replacement, "'")
}
value = value[:start] + replacement + value[end+1:]
}
return "", fmt.Errorf("recursive installer variable expansion")
}
for _, proc := range profile.Processors {
if len(proc.Sides) > 0 {
serverSide := false
for _, side := range proc.Sides {
serverSide = serverSide || side == "server"
}
if !serverSide {
continue
}
}
args := make([]string, len(proc.Args))
for i, arg := range proc.Args {
args[i], err = expand(arg)
if err != nil {
return err
}
}
mappingTask := false
var mappingOutput string
for i, arg := range args {
if arg == "DOWNLOAD_MOJMAPS" {
mappingTask = true
}
if arg == "--output" && i+1 < len(args) {
mappingOutput = args[i+1]
}
}
if mappingTask {
if mappingOutput == "" {
return fmt.Errorf("mojang mappings processor has no output")
}
if err := CopyLockedFile(filepath.Join(root, "server-mappings.txt"), mappingOutput, 0o644); err != nil {
return err
}
continue
}
p, err := routing.MavenPath(proc.Jar)
if err != nil {
return err
}
processorJar := filepath.Join(root, "libraries", p)
main, err := jarMain(processorJar)
if err != nil {
return err
}
classpath := []string{processorJar}
for _, coordinate := range proc.Classpath {
p, err := routing.MavenPath(coordinate)
if err != nil {
return err
}
classpath = append(classpath, filepath.Join(root, "libraries", p))
}
commandArgs := []string{
"-cp",
strings.Join(classpath, string(os.PathListSeparator)),
main,
}
commandArgs = append(commandArgs, args...)
if err := requireJava(java, "installer processor "+proc.Jar); err != nil {
return err
}
cmd := exec.CommandContext(ctx, java, commandArgs...)
cmd.Dir = root
captured, err := runLockedProcess(cmd, output)
if err != nil {
return fmt.Errorf("installer processor %s: %w\n%s", proc.Jar, err, captured)
}
for name, expected := range proc.Outputs {
destination, err := expand(name)
if err != nil {
return err
}
expected, err = expand(expected)
if err != nil {
return err
}
if err := checkProcessorOutput(destination, expected); err != nil {
return err
}
}
}
// Profile extraction processors generate the native server launch scripts.
if _, err := os.Stat(filepath.Join(root, "run.sh")); err != nil {
prefix := "net/neoforged/neoforge/"
if server.Product == "forge" {
prefix = "net/minecraftforge/forge/"
}
version := server.CoreVersion.String()
if server.Product == "forge" {
version = server.Minecraft.String() + "-" + version
}
argsPath := "libraries/" + prefix + version + "/unix_args.txt"
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(argsPath))); err != nil {
return fmt.Errorf("installer did not generate server launch arguments: %w", err)
}
if err := os.WriteFile(filepath.Join(root, "run.sh"), []byte("#!/bin/sh\ncd -- \"$(dirname -- \"$0\")\"\nexec java @user_jvm_args.txt @"+argsPath+" \"$@\"\n"), 0o755); err != nil {
return err
}
if _, err := os.Stat(filepath.Join(root, "user_jvm_args.txt")); os.IsNotExist(err) {
if err := os.WriteFile(filepath.Join(root, "user_jvm_args.txt"), nil, 0o644); err != nil {
return err
}
}
}
return nil
}
func jarMain(p string) (string, error) {
z, err := zip.OpenReader(p)
if err != nil {
return "", err
}
defer z.Close()
raw, err := readLockedEntry(&z.Reader, "META-INF/MANIFEST.MF")
if err != nil {
return "", err
}
for line := range strings.SplitSeq(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") {
if v, ok := strings.CutPrefix(line, "Main-Class: "); ok {
return strings.TrimSpace(v), nil
}
}
return "", fmt.Errorf("processor JAR has no Main-Class")
}
func checkProcessorOutput(filename, expected string) error {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
h := sha1.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
if actual := hex.EncodeToString(h.Sum(nil)); actual != expected {
return fmt.Errorf("processor output %s: expected sha1 %s, got %s", filename, expected, actual)
}
return nil
}
func writeFabricShimJar(path string, classpath []string) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
writer := zip.NewWriter(file)
defer writer.Close()
classpathLine := "Class-Path: " + strings.Join(classpath, " ")
manifest := "Manifest-Version: 1.0\nMain-Class: net.fabricmc.loader.impl.launch.server.FabricServerLauncher\n" + wrapLongLine(classpathLine, 70) + "\n"
wrapped := manifest
manifestEntry, err := writer.Create("META-INF/MANIFEST.MF")
if err != nil {
return err
}
if _, err := manifestEntry.Write([]byte(wrapped)); err != nil {
return err
}
propertiesEntry, err := writer.Create("fabric-server-launch.properties")
if err != nil {
return err
}
_, err = propertiesEntry.Write([]byte("launch.mainClass=net.fabricmc.loader.impl.launch.knot.KnotServer\n"))
return err
}
func wrapLongLine(line string, width int) string {
if len(line) <= width {
return line
}
var builder strings.Builder
for len(line) > width {
builder.WriteString(line[:width])
builder.WriteByte('\n')
builder.WriteByte(' ')
line = line[width:]
}
builder.WriteString(line)
return builder.String()
}
// Package cache owns request metadata and expiration policy over reusable CAS
// storage. Its objects have a separate lifecycle from project-local backups.
package cache
import (
"errors"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"github.com/mclucy/lucy/log"
)
// ErrNoHTTPClient reports a cache client constructed without a transport.
var ErrNoHTTPClient = errors.New("cache client has no http client")
// DefaultRoot reports the per-user directory the network cache lives in. It is
// a path helper for the construction boundary, not a cache instance.
func DefaultRoot() string {
dir, err := os.UserCacheDir()
if err != nil {
dir = os.TempDir()
}
return filepath.Join(dir, "lucy", "network")
}
// Client is one configured network cache: a content-addressed store, its
// index and expiration policy, and the HTTP transport used for requests.
//
// Construction is lazy. The store and index open on first use, so a missing or
// unusable root disables caching for this client while requests continue
// uncached; construction itself never touches the filesystem.
type Client struct {
root string
config Config
httpClient *http.Client
logger *log.Logger
lifetime sync.RWMutex
closed bool
once sync.Once
handler *handler
}
// New constructs a cache client rooted at root. A nil logger records nothing.
// httpClient must be supplied: a nil client makes network helpers fail rather
// than falling back to a process-wide default.
func New(
root string,
config Config,
httpClient *http.Client,
logger *log.Logger,
) *Client {
return &Client{
root: root,
config: config,
httpClient: httpClient,
logger: logger,
}
}
// Do executes a request through the configured transport so callers that fetch
// uncached bytes share the same HTTP configuration.
func (c *Client) Do(request *http.Request) (*http.Response, error) {
if c.httpClient == nil {
return nil, ErrNoHTTPClient
}
return c.httpClient.Do(request)
}
// Close waits for cache operations and closes the store if it was opened.
// It is safe to call repeatedly and never initializes an unused store.
func (c *Client) Close() error {
c.lifetime.Lock()
defer c.lifetime.Unlock()
if c.closed {
return nil
}
c.closed = true
c.once.Do(func() {
c.handler = &handler{logger: c.logger}
})
c.handler.on = false
if c.handler.store != nil {
return c.handler.store.Close()
}
return nil
}
func (c *Client) cache() *handler {
c.once.Do(func() {
c.handler = newHandler(c.root, c.config, c.logger)
})
return c.handler
}
// All returns every live index entry in key order.
func (c *Client) All() []*Entry {
c.lifetime.RLock()
defer c.lifetime.RUnlock()
return c.cache().all()
}
// ClearAll empties the index and reports the objects it collected.
func (c *Client) ClearAll() (ResetReport, error) {
c.lifetime.RLock()
defer c.lifetime.RUnlock()
return c.cache().clearAll()
}
// Get keeps a reader alive independently of later index replacements. The
// caller must Close it.
func (c *Client) Get(k string) (
hit bool,
reader io.ReadCloser,
filename string,
err error,
) {
c.lifetime.RLock()
defer c.lifetime.RUnlock()
return c.cache().get(k)
}
// GetBytes returns a cached entry's bytes.
func (c *Client) GetBytes(k string) (hit bool, data []byte, err error) {
c.lifetime.RLock()
defer c.lifetime.RUnlock()
return c.cache().getBytes(k)
}
// Exist reports whether k names a live entry.
func (c *Client) Exist(k string) bool {
c.lifetime.RLock()
defer c.lifetime.RUnlock()
return c.cache().exist(k)
}
// Remove drops the entry for k and reclaims unreferenced objects.
func (c *Client) Remove(k string) error {
c.lifetime.RLock()
defer c.lifetime.RUnlock()
return c.cache().remove(k)
}
// Flush publishes the in-memory index under the cache lock.
func (c *Client) Flush() error {
c.lifetime.RLock()
defer c.lifetime.RUnlock()
return c.cache().flush()
}
package cache
import (
"crypto/sha256"
"errors"
"fmt"
"hash"
"github.com/mclucy/lucy/cas"
"github.com/mclucy/lucy/log"
)
// byteStage can finish a buffered response without requiring temporary disk space.
type byteStage struct {
writer *cas.Writer
hasher hash.Hash
logger *log.Logger
}
func (c *Client) newByteStage() *byteStage {
writer, err := c.cache().newWriter()
if err == nil {
return &byteStage{writer: writer, logger: c.logger}
}
if !errors.Is(err, ErrDisabled) {
c.logger.Warn(fmt.Errorf("cache staging unavailable: %w", err))
}
return &byteStage{hasher: sha256.New(), logger: c.logger}
}
func (s *byteStage) detach() error {
hasher, err := s.writer.DetachHash()
if err != nil {
return err
}
if err := s.writer.Abort(); err != nil {
s.logger.Warn(fmt.Errorf("remove failed cache stage: %w", err))
}
s.hasher = hasher
s.writer = nil
return nil
}
func (s *byteStage) Write(data []byte) (int, error) {
if s.writer == nil {
return s.hasher.Write(data)
}
count, err := s.writer.Write(data)
if err == nil {
return count, nil
}
s.logger.Warn(fmt.Errorf("cache write failed, retaining buffered response: %w", err))
if err := s.detach(); err != nil {
return count, err
}
rest, err := s.hasher.Write(data[count:])
return count + rest, err
}
func (s *byteStage) seal() (cas.Object, error) {
if s.writer != nil {
object, err := s.writer.Seal(nil)
if err == nil {
return object, nil
}
s.logger.Warn(fmt.Errorf("cache sync failed, retaining buffered response: %w", err))
if err := s.detach(); err != nil {
return cas.Object{}, err
}
}
var digest cas.Digest
s.hasher.Sum(digest[:0])
return cas.Object{Digest: digest}, nil
}
func (s *byteStage) Close() error {
if s.writer != nil {
return s.writer.Abort()
}
return nil
}
package cache
import (
"fmt"
"time"
)
type Config struct {
Enabled bool `json:"enabled"`
DownloadMaxSize int64 `json:"download_max_size"`
DownloadKeepFor time.Duration `json:"download_keep_for"`
IndexMaxSize int64 `json:"index_max_size"`
IndexRefreshAfter time.Duration `json:"index_refresh_after"`
}
func DefaultCacheConfig() Config {
return Config{
Enabled: true,
DownloadMaxSize: 2 * 1024 * 1024 * 1024, // 2 GB
DownloadKeepFor: 7 * 24 * time.Hour, // 7 days
IndexMaxSize: 50 * 1024 * 1024, // 50 MB
IndexRefreshAfter: 4 * time.Hour, // 4 hours
}
}
func (c *Config) Validate() error {
if !c.Enabled {
return nil
}
if c.DownloadMaxSize <= 0 {
return fmt.Errorf("download_max_size must be positive, got %d", c.DownloadMaxSize)
}
if c.DownloadKeepFor <= 0 {
return fmt.Errorf("download_keep_for must be positive, got %s", c.DownloadKeepFor)
}
if c.IndexMaxSize <= 0 {
return fmt.Errorf("index_max_size must be positive, got %d", c.IndexMaxSize)
}
if c.IndexRefreshAfter <= 0 {
return fmt.Errorf("index_refresh_after must be positive, got %s", c.IndexRefreshAfter)
}
return nil
}
func (c *Config) toPolicy() Policy {
return Policy{
Metadata: PolicyConfig{
MaxSize: c.IndexMaxSize,
TTL: c.IndexRefreshAfter,
},
Artifact: PolicyConfig{
MaxSize: c.DownloadMaxSize,
TTL: c.DownloadKeepFor,
},
}
}
package cache
import "time"
// EntryKind distinguishes artifact entries (large, immutable binaries like
// server JARs and mods) from metadata entries (small, frequently refreshed
// manifests and version indexes).
type EntryKind uint8
const (
KindMetadata EntryKind = iota
KindArtifact
)
func (k EntryKind) String() string {
switch k {
case KindMetadata:
return "metadata"
case KindArtifact:
return "artifact"
default:
return "unknown"
}
}
// HashAlgorithm identifies the hash function used for integrity verification.
// Different upstream sources provide different algorithms: Mojang uses SHA-1,
// Modrinth provides SHA-1 and SHA-512, and Lucy uses SHA-256 internally for
// content addressing.
type HashAlgorithm uint8
const (
HashNone HashAlgorithm = iota
HashSHA1
HashSHA256
HashSHA512
)
func (h HashAlgorithm) String() string {
switch h {
case HashSHA1:
return "sha1"
case HashSHA256:
return "sha256"
case HashSHA512:
return "sha512"
default:
return "none"
}
}
// ParseHashAlgorithm maps s to a HashAlgorithm, returning HashNone for
// unrecognized inputs.
func ParseHashAlgorithm(s string) HashAlgorithm {
switch s {
case "sha1":
return HashSHA1
case "sha256":
return HashSHA256
case "sha512":
return HashSHA512
default:
return HashNone
}
}
// IntegrityState tracks whether a cached entry's content has been verified
// against an expected digest from an upstream source.
type IntegrityState uint8
const (
// IntegrityUnverified means no expected digest was available or verification
// has not run.
IntegrityUnverified IntegrityState = iota
// IntegrityVerified means the entry's content matched the expected
// digest at cache-add time.
IntegrityVerified
)
func (s IntegrityState) String() string {
switch s {
case IntegrityUnverified:
return "unverified"
case IntegrityVerified:
return "verified"
default:
return "unknown"
}
}
// Integrity holds the expected or actual digest of a cache entry along with
// its verification state. When Expected is empty, the entry operates in
// unverified mode (best-effort caching without integrity guarantees).
type Integrity struct {
Algorithm HashAlgorithm `json:"algorithm"`
Expected string `json:"expected,omitempty"`
Actual string `json:"actual,omitempty"`
State IntegrityState `json:"state"`
}
// Entry is the metadata record for one cached blob. ContentHash
// (always SHA-256) addresses the blob in the store; Integrity carries the
// upstream-provided digest, which may use a different algorithm (see
// HashAlgorithm).
type Entry struct {
Kind EntryKind `json:"kind"`
Filename string `json:"filename"`
Size int64 `json:"size"`
ContentHash string `json:"content_hash"`
Integrity Integrity `json:"integrity"`
Expiration time.Time `json:"expiration"`
Key string `json:"key"`
CreatedAt time.Time `json:"created_at"`
}
package cache
import (
"bytes"
"context"
"crypto/sha1"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"time"
"github.com/mclucy/lucy/cas"
"github.com/mclucy/lucy/internal/fsutil"
)
// DownloadOptions controls artifact caching and downloading. The URL passed to
// CachedDownload is the cache identity; RequestURL, when set, is used only for
// the network request.
type DownloadOptions struct {
Kind EntryKind
ExpectedHash string
HashAlgorithm HashAlgorithm
Filename string
RequestURL string
WrapReader func(io.Reader, int64) io.Reader
OnCacheHit func()
OnResolvedFilename func(string)
TTL time.Duration
FileMode os.FileMode
}
type BytesRequestOptions struct {
Kind EntryKind
Headers http.Header
ExpectedHash string
HashAlgorithm HashAlgorithm
TTL time.Duration
MaxBytes int64
}
type BytesResponse struct {
StatusCode int
Data []byte
CacheHit bool
}
type DownloadResult struct {
File *os.File
CacheHit bool
Verified bool
}
// CachedDownload downloads a file from url into dir, using the cache for
// deduplication. On cache hit the indexed bytes are copied atomically into
// dir and OnCacheHit (if set) is called. On miss the response body is
// streamed once through an optional WrapReader (for progress tracking) into
// a cache stage, verified, and only then written to the destination and
// published to the cache.
func (c *Client) CachedDownload(
ctx context.Context,
url, dir string,
opts DownloadOptions,
) (*DownloadResult, error) {
c.lifetime.RLock()
defer c.lifetime.RUnlock()
if err := ctx.Err(); err != nil {
return nil, err
}
if err := validateDownloadURL(url); err != nil {
return nil, err
}
requestURL := url
if opts.RequestURL != "" {
requestURL = opts.RequestURL
}
if err := validateDownloadURL(requestURL); err != nil {
return nil, err
}
if opts.FileMode == 0 {
opts.FileMode = 0o640
}
hit, cachedReader, cachedFilename, err := c.cache().get(url)
if err != nil {
c.logger.Warn(
fmt.Errorf(
"cache lookup failed, proceeding with download: %w",
err,
),
)
}
if hit && cachedReader != nil {
result, copyErr := copyCachedFile(
ctx,
cachedReader,
cachedFilename,
dir,
opts,
)
closeErr := cachedReader.Close()
if copyErr == nil {
if closeErr != nil {
return nil, errors.Join(closeErr, result.File.Close())
}
return result, nil
}
copyErr = errors.Join(copyErr, closeErr)
if !errors.Is(copyErr, cas.ErrCorrupt) &&
!errors.Is(copyErr, cas.ErrRead) {
return nil, copyErr
}
c.logger.Warn(
fmt.Errorf(
"cached contents unreadable, refetching %s: %w",
url,
copyErr,
),
)
}
return c.downloadAndCache(ctx, url, requestURL, dir, opts)
}
// copyCachedFile materializes a cache hit under dir. cas.ErrCorrupt and
// cas.ErrRead mean the stored bytes are unusable and the caller must treat
// the hit as a miss; the atomic copy leaves the destination untouched.
func copyCachedFile(
ctx context.Context,
reader io.Reader,
cachedFilename string,
dir string,
opts DownloadOptions,
) (*DownloadResult, error) {
resolvedName := sanitizeFilename(cachedFilename, "artifact")
if opts.OnResolvedFilename != nil {
opts.OnResolvedFilename(resolvedName)
}
destPath := filepath.Join(dir, resolvedName)
if !containedUnder(dir, destPath) {
return nil, fmt.Errorf(
"cached filename %q escapes destination directory",
resolvedName,
)
}
destFile, err := copyFileContext(ctx, reader, destPath, opts.FileMode)
if err != nil {
return nil, fmt.Errorf(
"failed to copy cached file to destination: %w",
err,
)
}
if opts.OnCacheHit != nil {
opts.OnCacheHit()
}
if err := ctx.Err(); err != nil {
return nil, errors.Join(err, destFile.Close())
}
return &DownloadResult{
File: destFile,
CacheHit: true,
Verified: false,
}, nil
}
type contextReader struct {
ctx context.Context
reader io.Reader
}
func (r contextReader) Read(p []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
n, err := r.reader.Read(p)
if ctxErr := r.ctx.Err(); ctxErr != nil {
return n, ctxErr
}
return n, err
}
func copyFileContext(ctx context.Context, reader io.Reader, path string, mode os.FileMode) (file *os.File, resultErr error) {
dir := filepath.Dir(path)
if err := fsutil.MkdirAllPrivate(dir); err != nil {
return nil, err
}
root, err := os.OpenRoot(dir)
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, root.Close()) }()
if err := fsutil.WriteRoot(root, filepath.Base(path), contextReader{ctx, reader}, fsutil.WriteOptions{
Mode: mode, Replace: true, Check: ctx.Err,
}); err != nil {
return nil, err
}
return os.Open(path)
}
// CachedGetBytes fetches bytes from url, using the cache for deduplication.
// On cache hit the bytes are returned directly. On miss the response body is
// read into memory with a size limit, staged for content addressing, verified
// against the optional upstream digest, and then cached.
func (c *Client) CachedGetBytes(
ctx context.Context,
url string,
opts BytesRequestOptions,
) ([]byte, error) {
res, err := c.CachedGetRequest(ctx, url, opts)
if err != nil {
return nil, err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("fetch failed: status %d", res.StatusCode)
}
return res.Data, nil
}
// CachedGetRequest fetches bytes from url and returns the HTTP status code
// separately from transport/read/cache errors. Non-2xx responses are not
// cached.
func (c *Client) CachedGetRequest(
ctx context.Context,
url string,
opts BytesRequestOptions,
) (*BytesResponse, error) {
c.lifetime.RLock()
defer c.lifetime.RUnlock()
if err := ctx.Err(); err != nil {
return nil, err
}
if err := validateDownloadURL(url); err != nil {
return nil, err
}
hit, data, err := c.cache().getBytesContext(ctx, url)
if err := ctx.Err(); err != nil {
return nil, err
}
if err != nil {
c.logger.Warn(
fmt.Errorf(
"cache lookup failed, proceeding with fetch: %w",
err,
),
)
}
if hit && data != nil {
return &BytesResponse{
StatusCode: http.StatusOK,
Data: data,
CacheHit: true,
}, nil
}
maxBytes := opts.MaxBytes
if maxBytes == 0 {
maxBytes = 50 * 1024 * 1024
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create fetch request failed: %w", err)
}
for key, values := range opts.Headers {
for _, value := range values {
req.Header.Add(key, value)
}
}
resp, err := c.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, err := readLimitedBytes(contextReader{ctx, resp.Body}, maxBytes)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
return &BytesResponse{StatusCode: resp.StatusCode, Data: body}, nil
}
stage := c.newByteStage()
defer func() {
if err := stage.Close(); err != nil {
c.logger.Warn(fmt.Errorf("remove download stage: %w", err))
}
}()
writers := []io.Writer{stage}
integrityHasher := newIntegrityHasher(opts.HashAlgorithm, opts.ExpectedHash)
if integrityHasher != nil {
writers = append(writers, integrityHasher)
}
var bodyBuf bytes.Buffer
writers = append(writers, &bodyBuf)
limitedReader := io.LimitReader(contextReader{ctx, resp.Body}, maxBytes)
if _, err := io.Copy(io.MultiWriter(writers...), limitedReader); err != nil {
return nil, fmt.Errorf("read failed: %w", err)
}
body := bodyBuf.Bytes()
if int64(len(body)) >= maxBytes {
return nil, fmt.Errorf(
"response too large: exceeded %d bytes",
maxBytes,
)
}
object, err := stage.seal()
if err != nil {
return nil, err
}
contentDigest := object.Digest
integrity, _, err := c.verifyIntegrity(
integrityActual(opts.HashAlgorithm, contentDigest, integrityHasher),
opts.HashAlgorithm,
opts.ExpectedHash,
url,
)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
if stage.writer != nil {
if err := c.cache().commitEntryContext(
ctx,
stage.writer, "", url, opts.Kind, integrity, opts.TTL,
); err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
c.logger.Warn(fmt.Errorf("failed to cache bytes: %w", err))
}
}
if err := ctx.Err(); err != nil {
return nil, err
}
return &BytesResponse{
StatusCode: resp.StatusCode,
Data: body,
CacheHit: false,
}, nil
}
func readLimitedBytes(reader io.Reader, maxBytes int64) ([]byte, error) {
bytes, err := fsutil.CopyBytes(reader, maxBytes)
if err != nil {
return nil, fmt.Errorf("read failed: %w", err)
}
return bytes, nil
}
// downloadAndCache streams requestURL into dir. The destination write and
// the cache commit happen only after the staged SHA-256 digest and any
// upstream integrity check pass, so a rejected response reaches neither.
func (c *Client) downloadAndCache(
ctx context.Context,
url, requestURL, dir string,
opts DownloadOptions,
) (*DownloadResult, error) {
if err := validateDownloadURL(url); err != nil {
return nil, err
}
if err := validateDownloadURL(requestURL); err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, fmt.Errorf("download failed: %w", err)
}
resp, err := c.Do(request)
if err != nil {
return nil, fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("download failed: status %d", resp.StatusCode)
}
filename := opts.Filename
if filename == "" {
filename = speculateFilename(resp)
}
if opts.OnResolvedFilename != nil && filename != "" {
opts.OnResolvedFilename(filename)
}
stage, err := c.newDownloadStage()
if err != nil {
return nil, err
}
defer func() {
if err := stage.Close(); err != nil {
c.logger.Warn(fmt.Errorf("remove download stage: %w", err))
}
}()
writers := []io.Writer{stage}
integrityHasher := newIntegrityHasher(opts.HashAlgorithm, opts.ExpectedHash)
if integrityHasher != nil {
writers = append(writers, integrityHasher)
}
var reader io.Reader = resp.Body
if opts.WrapReader != nil {
reader = opts.WrapReader(reader, resp.ContentLength)
}
if _, err := io.Copy(io.MultiWriter(writers...), contextReader{ctx, reader}); err != nil {
return nil, fmt.Errorf("download stream failed: %w", err)
}
object, err := stage.seal()
if err != nil {
return nil, err
}
contentDigest := object.Digest
integrity, verified, err := c.verifyIntegrity(
integrityActual(opts.HashAlgorithm, contentDigest, integrityHasher),
opts.HashAlgorithm,
opts.ExpectedHash,
url,
)
if err != nil {
return nil, err
}
filename = sanitizeFilename(filename, contentDigest.Hex())
destPath := filepath.Join(dir, filename)
if !containedUnder(dir, destPath) {
return nil, fmt.Errorf(
"downloaded filename %q escapes destination directory",
filename,
)
}
if err := stage.writer.Rewind(); err != nil {
return nil, fmt.Errorf("rewind staged download: %w", err)
}
destFile, err := copyFileContext(ctx, stage.writer, destPath, opts.FileMode)
if err != nil {
return nil, fmt.Errorf("write downloaded file: %w", err)
}
if err := ctx.Err(); err != nil {
return nil, errors.Join(err, destFile.Close())
}
if stage.cacheable {
if err := c.cache().commitEntryContext(
ctx,
stage.writer, filename, url, opts.Kind, integrity, opts.TTL,
); err != nil {
if ctx.Err() != nil {
return nil, errors.Join(ctx.Err(), destFile.Close())
}
c.logger.Warn(fmt.Errorf("failed to cache downloaded file: %w", err))
}
}
if err := ctx.Err(); err != nil {
return nil, errors.Join(err, destFile.Close())
}
return &DownloadResult{
File: destFile,
CacheHit: false,
Verified: verified,
}, nil
}
// newIntegrityHasher creates the hasher for an upstream digest, or nil when
// the content SHA-256 already covers that expectation or none was supplied.
func newIntegrityHasher(algorithm HashAlgorithm, expectedHash string) hash.Hash {
if expectedHash == "" || algorithm == HashNone || algorithm == HashSHA256 {
return nil
}
switch algorithm {
case HashSHA1:
return sha1.New()
case HashSHA512:
return sha512.New()
default:
return nil
}
}
// integrityActual reports the digest compared against the upstream
// expectation: the content digest for SHA-256, the separate hasher otherwise.
func integrityActual(
algorithm HashAlgorithm,
content cas.Digest,
hasher hash.Hash,
) string {
if algorithm == HashSHA256 {
return content.Hex()
}
if hasher != nil {
return hex.EncodeToString(hasher.Sum(nil))
}
return ""
}
func (c *Client) verifyIntegrity(
actualHash string,
algorithm HashAlgorithm,
expectedHash string,
url string,
) (Integrity, bool, error) {
integrity := Integrity{State: IntegrityUnverified}
verified := false
if expectedHash != "" && actualHash != "" {
if actualHash != expectedHash {
return integrity, false, fmt.Errorf(
"integrity verification failed (%s): expected %s, got %s",
algorithm, expectedHash, actualHash,
)
}
integrity = Integrity{
Algorithm: algorithm,
Expected: expectedHash,
Actual: actualHash,
State: IntegrityVerified,
}
verified = true
c.logger.Debug(
fmt.Sprintf(
"integrity verified (%s): %s",
algorithm,
url,
),
)
}
return integrity, verified, nil
}
func validateDownloadURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid download URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf(
"unsupported URL scheme %q (only http/https)",
u.Scheme,
)
}
return nil
}
package cache
import (
"mime"
"net/http"
"net/url"
"path/filepath"
"strings"
)
func speculateFilename(resp *http.Response) string {
if filename, ok := getFilenameFromHeader(resp); ok {
return filename
}
filename := getFilenameFromURL(resp.Request.URL.String())
return filename
}
func getFilenameFromHeader(resp *http.Response) (string, bool) {
contentDisposition := resp.Header.Get("Content-Disposition")
if contentDisposition == "" {
return "", false
}
_, params, err := mime.ParseMediaType(contentDisposition)
if err != nil {
return "", false
}
filename, ok := params["filename"]
return filename, ok
}
func getFilenameFromURL(urlString string) string {
u, err := url.Parse(urlString)
if err != nil {
return ""
}
_, filename, found := strings.CutLast(u.Path, "/")
if !found {
return u.Path
}
return filename
}
// sanitizeFilename prevents path traversal by stripping directory components.
func sanitizeFilename(name, fallback string) string {
name = filepath.Base(name)
if !filepath.IsLocal(name) || name == "." || name == ".." || name == "/" || name == string(filepath.Separator) {
return fallback
}
return name
}
// containedUnder reports whether child is strictly inside parent.
func containedUnder(parent, child string) bool {
absParent, err := filepath.Abs(parent)
if err != nil {
return false
}
absChild, err := filepath.Abs(child)
if err != nil {
return false
}
prefix := absParent
if !strings.HasSuffix(prefix, string(filepath.Separator)) {
prefix += string(filepath.Separator)
}
return strings.HasPrefix(absChild, prefix)
}
package cache
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"maps"
"path/filepath"
"slices"
"sync"
"time"
"github.com/mclucy/lucy/cas"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/log"
)
var ErrDisabled = errors.New("cache is disabled")
type handler struct {
mu sync.Mutex
on bool
dir string
store *cas.Store
index *index
policy Policy
logger *log.Logger
}
func newHandler(root string, cfg Config, logger *log.Logger) *handler {
h := &handler{
on: cfg.Enabled,
dir: root,
policy: cfg.toPolicy(),
logger: logger,
}
if !h.on {
return h
}
if err := h.initialize(cfg); err != nil {
logger.Warn(fmt.Errorf("disable cache at %s: %w", root, err))
h.on = false
if h.store != nil {
if err := h.store.Close(); err != nil {
logger.Warn(err)
}
h.store = nil
}
}
return h
}
func (h *handler) initialize(cfg Config) (resultErr error) {
if err := cfg.Validate(); err != nil {
return err
}
if err := fsutil.MkdirAllPrivate(h.dir); err != nil {
return err
}
lock, err := fsutil.LockFile(h.dir + ".lock")
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
h.store, err = cas.New(filepath.Join(h.dir, "cas"))
if err != nil {
return err
}
h.index = newIndex(filepath.Join(h.dir, manifestFilename))
if _, err = h.index.load(); err != nil {
return err
}
removed, err := h.maintainLocked(nil)
if err != nil {
return err
}
if len(removed) != 0 {
if err := h.index.flush(); err != nil {
return err
}
}
if err := h.collectAllLocked(nil); err != nil {
return err
}
return h.store.Cleanup()
}
func (h *handler) lockIndex() (*fsutil.FileLock, error) {
if !h.on {
return nil, ErrDisabled
}
lock, err := fsutil.LockFile(h.dir + ".lock")
if err != nil {
return nil, err
}
reset, err := h.index.load()
if err == nil && reset {
err = h.collectAllLocked(nil)
}
if err != nil {
return nil, errors.Join(err, lock.Close())
}
return lock, nil
}
func (h *handler) newWriter() (*cas.Writer, error) {
h.mu.Lock()
defer h.mu.Unlock()
if !h.on {
return nil, ErrDisabled
}
return h.store.NewWriter()
}
func (h *handler) add(data []byte, filename, k string, expiration time.Duration) error {
return h.addEntry(data, filename, k, KindArtifact, Integrity{State: IntegrityUnverified}, expiration)
}
func (h *handler) addEntry(data []byte, filename, k string, kind EntryKind, integrity Integrity, expiration time.Duration) (resultErr error) {
writer, err := h.newWriter()
if errors.Is(err, ErrDisabled) {
return nil
}
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, writer.Abort()) }()
if _, err := io.Copy(writer, bytes.NewReader(data)); err != nil {
return err
}
if _, err := writer.Seal(nil); err != nil {
return err
}
return h.commitEntry(writer, filename, k, kind, integrity, expiration)
}
// commitEntry publishes bytes before their reference and reclaims only after the index commits.
func (h *handler) commitEntry(writer *cas.Writer, filename, k string, kind EntryKind, integrity Integrity, expiration time.Duration) (resultErr error) {
return h.commitEntryContext(context.Background(), writer, filename, k, kind, integrity, expiration)
}
func (h *handler) commitEntryContext(ctx context.Context, writer *cas.Writer, filename, k string, kind EntryKind, integrity Integrity, expiration time.Duration) (resultErr error) {
h.mu.Lock()
defer h.mu.Unlock()
lock, err := h.lockIndex()
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
if err := ctx.Err(); err != nil {
return err
}
object, err := writer.Commit(nil)
if err != nil {
return err
}
if expiration == 0 {
expiration = h.policy.ConfigFor(kind).TTL
}
ckey := canonicalizeKey(k)
now := time.Now()
created := now
contentHash := object.Digest.String()
var removed []cas.Digest
if old, ok := h.index.get(ckey); ok {
created = old.CreatedAt
if old.ContentHash != contentHash {
digest, err := cas.ParseDigest(old.ContentHash)
if err != nil {
return err
}
removed = append(removed, digest)
}
}
h.index.put(ckey, &Entry{
Kind: kind,
Filename: sanitizeFilename(filename, object.Digest.Hex()),
Size: object.Size,
ContentHash: contentHash,
Integrity: integrity,
Expiration: now.Add(expiration),
Key: string(ckey),
CreatedAt: created,
})
removed, err = h.maintainLocked(removed)
if err != nil {
return err
}
if err := h.index.flush(); err != nil {
return err
}
return h.collectCandidatesLocked(removed)
}
func (h *handler) flush() (resultErr error) {
h.mu.Lock()
defer h.mu.Unlock()
if !h.on {
return nil
}
lock, err := h.lockIndex()
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
return h.index.flush()
}
func (h *handler) exist(k string) bool {
hit, reader, _, err := h.get(k)
if err != nil {
h.logger.Warn(err)
return false
}
if reader != nil {
if err := reader.Close(); err != nil {
h.logger.Warn(err)
return false
}
}
return hit
}
// get keeps a reader alive independently of later index replacements. The caller must Close it.
func (h *handler) get(k string) (hit bool, reader io.ReadCloser, filename string, resultErr error) {
h.mu.Lock()
defer h.mu.Unlock()
if !h.on {
return false, nil, "", nil
}
lock, err := h.lockIndex()
if err != nil {
return false, nil, "", err
}
defer func() {
resultErr = errors.Join(resultErr, lock.Close())
if resultErr != nil && reader != nil {
resultErr = errors.Join(resultErr, reader.Close())
hit, reader, filename = false, nil, ""
}
}()
entry, ok := h.index.get(canonicalizeKey(k))
if !ok || !entry.Expiration.After(time.Now()) {
return false, nil, "", nil
}
digest, err := cas.ParseDigest(entry.ContentHash)
if err != nil {
return false, nil, "", err
}
reader, err = h.store.Open(digest)
if err != nil {
return false, nil, "", err
}
return true, reader, entry.Filename, nil
}
func (h *handler) getBytes(k string) (hit bool, data []byte, resultErr error) {
return h.getBytesContext(context.Background(), k)
}
func (h *handler) getBytesContext(ctx context.Context, k string) (hit bool, data []byte, resultErr error) {
hit, reader, _, err := h.get(k)
if err != nil || !hit {
return false, nil, err
}
defer func() { resultErr = errors.Join(resultErr, reader.Close()) }()
data, err = io.ReadAll(contextReader{ctx, reader})
if err != nil {
return false, nil, err
}
return true, data, nil
}
func (h *handler) remove(k string) (resultErr error) {
h.mu.Lock()
defer h.mu.Unlock()
if !h.on {
return nil
}
lock, err := h.lockIndex()
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
entry, ok := h.index.get(canonicalizeKey(k))
if !ok {
return nil
}
digest, err := cas.ParseDigest(entry.ContentHash)
if err != nil {
return err
}
h.index.delete(canonicalizeKey(k))
if err := h.index.flush(); err != nil {
return err
}
return h.collectCandidatesLocked([]cas.Digest{digest})
}
func (h *handler) all() []*Entry {
h.mu.Lock()
defer h.mu.Unlock()
if !h.on {
return nil
}
lock, err := h.lockIndex()
if err != nil {
h.logger.Warn(err)
return nil
}
defer func() {
if err := lock.Close(); err != nil {
h.logger.Warn(err)
}
}()
keys := slices.Sorted(maps.Keys(h.index.entries))
entries := make([]*Entry, 0, len(keys))
for _, k := range keys {
entry := *h.index.entries[k]
entries = append(entries, &entry)
}
return entries
}
func (h *handler) clearAll() (report ResetReport, resultErr error) {
h.mu.Lock()
defer h.mu.Unlock()
if !h.on {
return report, nil
}
lock, err := h.lockIndex()
if err != nil {
return report, err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
h.index.entries = make(map[key]*Entry)
if err := h.index.flush(); err != nil {
return report, err
}
if err := h.collectAllLocked(&report); err != nil {
return report, err
}
return report, h.store.Cleanup()
}
package cache
import (
"encoding/json/v2"
"errors"
"fmt"
"os"
"github.com/mclucy/lucy/cas"
"github.com/mclucy/lucy/internal/fsutil"
)
const indexVersion = 3
type indexManifest struct {
Version int `json:"version"`
Entries map[key]*Entry `json:"entries"`
}
type index struct {
path string
entries map[key]*Entry
info os.FileInfo
}
func newIndex(path string) *index {
return &index{path: path, entries: make(map[key]*Entry)}
}
// load runs under cache ownership. Atomic replacement makes inode identity a safe fast path.
func (idx *index) load() (bool, error) {
info, err := os.Stat(idx.path)
if errors.Is(err, os.ErrNotExist) {
idx.entries = make(map[key]*Entry)
return true, idx.flush()
}
if err != nil {
return false, err
}
if idx.info != nil && os.SameFile(info, idx.info) && info.Size() == idx.info.Size() && info.ModTime().Equal(idx.info.ModTime()) {
return false, nil
}
file, err := os.Open(idx.path)
if err != nil {
return false, err
}
var manifest indexManifest
decodeErr := json.UnmarshalRead(file, &manifest)
info, statErr := file.Stat()
closeErr := file.Close()
if statErr != nil || closeErr != nil {
return false, errors.Join(statErr, closeErr)
}
if decodeErr != nil || !validIndex(manifest) {
idx.entries = make(map[key]*Entry)
return true, idx.flush()
}
idx.info = info
idx.entries = manifest.Entries
return false, nil
}
func validIndex(manifest indexManifest) bool {
if manifest.Version != indexVersion || manifest.Entries == nil {
return false
}
for k, entry := range manifest.Entries {
if entry == nil || entry.Size < 0 || entry.Kind > KindArtifact || entry.Key != string(k) {
return false
}
digest, err := cas.ParseDigest(entry.ContentHash)
if err != nil || entry.Filename == "" || sanitizeFilename(entry.Filename, digest.Hex()) != entry.Filename {
return false
}
}
return true
}
func (idx *index) flush() error {
data, err := json.Marshal(indexManifest{
Version: indexVersion,
Entries: idx.entries,
})
if err != nil {
idx.info = nil
return fmt.Errorf("encode cache index: %w", err)
}
if err := fsutil.AtomicWrite(idx.path, data, 0o600); err != nil {
idx.info = nil
return fmt.Errorf("publish cache index: %w", err)
}
idx.info, err = os.Stat(idx.path)
return err
}
func (idx *index) get(k key) (*Entry, bool) {
entry, ok := idx.entries[k]
return entry, ok
}
func (idx *index) put(k key, entry *Entry) {
idx.entries[k] = entry
}
func (idx *index) delete(k key) {
delete(idx.entries, k)
}
func (idx *index) exists(k key) bool {
_, ok := idx.entries[k]
return ok
}
func (idx *index) all() map[key]*Entry {
return idx.entries
}
package cache
import (
"path/filepath"
"time"
"github.com/mclucy/lucy/cas"
)
const manifestFilename = "cache.json"
type key string
// ResetReport counts unique objects actually removed, not logical URL references.
// Removed lists the paths of those objects.
type ResetReport struct {
TotalFreedSize int64
FileCount int
Removed []string
}
func (h *handler) maintainLocked(removed []cas.Digest) ([]cas.Digest, error) {
for _, k := range expiredEntries(h.index.entries, time.Now()) {
digest, err := cas.ParseDigest(h.index.entries[k].ContentHash)
if err != nil {
return nil, err
}
removed = append(removed, digest)
h.index.delete(k)
}
for _, target := range evictionCandidates(h.index.entries, h.policy) {
digest, err := cas.ParseDigest(h.index.entries[target.key].ContentHash)
if err != nil {
return nil, err
}
removed = append(removed, digest)
h.index.delete(target.key)
}
return removed, nil
}
func (h *handler) referencedLocked(digest cas.Digest) bool {
value := digest.String()
for _, entry := range h.index.entries {
if entry.ContentHash == value {
return true
}
}
return false
}
func (h *handler) collectCandidatesLocked(digests []cas.Digest) error {
for _, digest := range digests {
if h.referencedLocked(digest) {
continue
}
if _, err := h.store.Remove(digest); err != nil {
return err
}
}
return nil
}
func (h *handler) collectAllLocked(report *ResetReport) error {
references := make(map[cas.Digest]struct{}, len(h.index.entries))
for _, entry := range h.index.entries {
digest, err := cas.ParseDigest(entry.ContentHash)
if err != nil {
return err
}
references[digest] = struct{}{}
}
return h.store.Walk(func(object cas.Object) error {
if _, ok := references[object.Digest]; ok {
return nil
}
freed, err := h.store.Remove(object.Digest)
if err != nil {
return err
}
if report != nil {
report.TotalFreedSize += freed
report.FileCount++
hex := object.Digest.Hex()
report.Removed = append(report.Removed, filepath.Join(h.dir, "cas", "objects", "sha256", hex[:2], hex))
}
return nil
})
}
package cache
import (
"fmt"
"time"
)
type PolicyConfig struct {
MaxSize int64 `json:"max_size"`
TTL time.Duration `json:"ttl"`
}
type Policy struct {
Metadata PolicyConfig `json:"metadata"`
Artifact PolicyConfig `json:"artifact"`
}
func (p *Policy) ConfigFor(kind EntryKind) PolicyConfig {
switch kind {
case KindMetadata:
return p.Metadata
case KindArtifact:
return p.Artifact
default:
return p.Artifact
}
}
func (p *Policy) Validate() error {
if err := p.Metadata.validate("metadata"); err != nil {
return err
}
return p.Artifact.validate("artifact")
}
func (c *PolicyConfig) validate(name string) error {
if c.MaxSize <= 0 {
return fmt.Errorf(
"invalid %s policy: max_size must be positive, got %d",
name, c.MaxSize,
)
}
if c.TTL <= 0 {
return fmt.Errorf(
"invalid %s policy: ttl must be positive, got %s",
name, c.TTL,
)
}
return nil
}
package cache
import (
"errors"
"fmt"
"os"
"github.com/mclucy/lucy/cas"
"github.com/mclucy/lucy/log"
)
// downloadStage retains the stream when optional cache storage cannot accept it.
type downloadStage struct {
writer *cas.Writer
cacheable bool
temporary *cas.Store
directory string
logger *log.Logger
}
func (c *Client) newDownloadStage() (*downloadStage, error) {
writer, err := c.cache().newWriter()
stage := &downloadStage{
writer: writer,
cacheable: err == nil,
logger: c.logger,
}
if err == nil {
return stage, nil
}
if !errors.Is(err, ErrDisabled) {
c.logger.Warn(fmt.Errorf("cache staging unavailable: %w", err))
}
if err := stage.fallback(); err != nil {
return nil, err
}
return stage, nil
}
func (s *downloadStage) fallback() error {
directory, err := os.MkdirTemp("", "lucy-download-")
if err != nil {
return err
}
store, err := cas.New(directory)
if err != nil {
return errors.Join(err, os.RemoveAll(directory))
}
var writer *cas.Writer
if s.writer == nil {
writer, err = store.NewWriter()
} else {
writer, err = s.writer.MoveTo(store)
}
if err != nil {
return errors.Join(err, store.Close(), os.RemoveAll(directory))
}
if s.writer != nil {
if err := s.writer.Abort(); err != nil {
s.logger.Warn(fmt.Errorf("remove failed cache stage: %w", err))
}
}
s.writer = writer
s.cacheable = false
s.temporary = store
s.directory = directory
return nil
}
func (s *downloadStage) Write(data []byte) (int, error) {
count, err := s.writer.Write(data)
if err == nil || !s.cacheable {
return count, err
}
s.logger.Warn(fmt.Errorf("cache stage write failed, retaining uncached download: %w", err))
if err := s.fallback(); err != nil {
return count, err
}
rest, err := s.writer.Write(data[count:])
return count + rest, err
}
func (s *downloadStage) seal() (cas.Object, error) {
object, err := s.writer.Seal(nil)
if err == nil || !s.cacheable {
return object, err
}
s.logger.Warn(fmt.Errorf("cache stage sync failed, retaining uncached download: %w", err))
if err := s.fallback(); err != nil {
return cas.Object{}, err
}
return s.writer.Seal(nil)
}
func (s *downloadStage) Close() error {
result := s.writer.Abort()
if s.temporary != nil {
result = errors.Join(result, s.temporary.Close(), os.RemoveAll(s.directory))
}
return result
}
package cache
import (
"cmp"
"maps"
"net/url"
"path"
"slices"
"strings"
"time"
)
// expiredEntries uses the same boundary as cache lookups: an entry is dead
// once its expiration is reached.
func expiredEntries(entries map[key]*Entry, now time.Time) []key {
var expired []key
for k, entry := range entries {
if !entry.Expiration.After(now) {
expired = append(expired, k)
}
}
return expired
}
type evictionTarget struct {
key key
exp time.Time
}
func evictionCandidates(
entries map[key]*Entry,
policy Policy,
) []evictionTarget {
type objectKey struct {
kind EntryKind
hash string
}
totals := map[EntryKind]int64{}
references := make(map[objectKey]int, len(entries))
targets := make([]evictionTarget, 0, len(entries))
for k, entry := range entries {
object := objectKey{kind: entry.Kind, hash: entry.ContentHash}
if references[object] == 0 {
totals[entry.Kind] += entry.Size
}
references[object]++
targets = append(targets, evictionTarget{key: k, exp: entry.Expiration})
}
slices.SortFunc(targets, func(a, b evictionTarget) int {
if c := a.exp.Compare(b.exp); c != 0 {
return c
}
return cmp.Compare(a.key, b.key)
})
var result []evictionTarget
for _, target := range targets {
entry := entries[target.key]
if totals[entry.Kind] <= policy.ConfigFor(entry.Kind).MaxSize {
continue
}
result = append(result, target)
object := objectKey{kind: entry.Kind, hash: entry.ContentHash}
references[object]--
if references[object] == 0 {
totals[entry.Kind] -= entry.Size
}
}
return result
}
func canonicalizeKey(k string) key {
u, err := url.Parse(k)
if err != nil || u.Scheme == "" {
return key(k)
}
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
host := u.Hostname()
port := u.Port()
if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") {
u.Host = host
}
if u.Path != "" {
u.Path = path.Clean(u.Path)
}
if u.RawQuery != "" {
params := u.Query()
keys := slices.Sorted(maps.Keys(params))
var parts []string
for _, k := range keys {
vals := slices.Clone(params[k])
slices.Sort(vals)
for _, v := range vals {
parts = append(parts, url.QueryEscape(k)+"="+url.QueryEscape(v))
}
}
u.RawQuery = strings.Join(parts, "&")
}
u.Fragment = ""
u.RawFragment = ""
return key(u.String())
}
// Package cas stores immutable contents independently of consumer metadata.
package cas
import (
"crypto/sha256"
"encoding/hex"
"fmt"
)
// Digest is an SHA-256 address. Its serialized form includes the algorithm name.
type Digest [sha256.Size]byte
// ParseDigest accepts only the canonical sha256:<lowercase-hex> representation.
func ParseDigest(value string) (Digest, error) {
var digest Digest
if len(value) != 7+sha256.Size*2 || value[:7] != "sha256:" {
return digest, fmt.Errorf("expected sha256:<64 lowercase hexadecimal characters>")
}
for i := range digest {
high, highOK := hexDigit(value[7+i*2])
low, lowOK := hexDigit(value[8+i*2])
if !highOK || !lowOK {
return Digest{}, fmt.Errorf("invalid sha256 digest")
}
digest[i] = high<<4 | low
}
return digest, nil
}
func hexDigit(value byte) (byte, bool) {
switch {
case value >= '0' && value <= '9':
return value - '0', true
case value >= 'a' && value <= 'f':
return value - 'a' + 10, true
default:
return 0, false
}
}
func (d Digest) String() string {
var text [7 + sha256.Size*2]byte
copy(text[:], "sha256:")
hex.Encode(text[7:], d[:])
return string(text[:])
}
// Hex is the filename-safe digest without its algorithm prefix.
func (d Digest) Hex() string {
return hex.EncodeToString(d[:])
}
func (d Digest) MarshalText() ([]byte, error) {
text := make([]byte, 7+sha256.Size*2)
copy(text, "sha256:")
hex.Encode(text[7:], d[:])
return text, nil
}
func (d *Digest) UnmarshalText(text []byte) error {
parsed, err := ParseDigest(string(text))
if err != nil {
return err
}
*d = parsed
return nil
}
// Object records storage facts, not a consumer's filename or retention policy.
type Object struct {
Digest Digest `json:"digest"`
Size int64 `json:"size"`
}
package cas
import (
"errors"
"fmt"
"hash"
"io"
"os"
)
type verifiedReader struct {
file *os.File
expected Digest
hash hash.Hash
finished bool
err error
}
func (r *verifiedReader) Read(data []byte) (int, error) {
if r.finished {
return 0, r.err
}
count, err := r.file.Read(data)
if count > 0 {
if _, hashErr := r.hash.Write(data[:count]); hashErr != nil {
return count, errors.Join(ErrRead, hashErr)
}
}
if errors.Is(err, io.EOF) {
r.finished = true
var actual Digest
r.hash.Sum(actual[:0])
if actual != r.expected {
r.err = fmt.Errorf("%w: expected %s, got %s", ErrCorrupt, r.expected.String(), actual.String())
} else {
r.err = io.EOF
}
return count, r.err
}
if err != nil {
return count, errors.Join(ErrRead, err)
}
return count, nil
}
func (r *verifiedReader) Close() error {
return r.file.Close()
}
package cas
import (
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/mclucy/lucy/internal/fsutil"
)
var (
ErrCorrupt = errors.New("cas contents do not match their digest")
ErrRead = errors.New("cannot read cas contents")
)
// Store has one private filesystem root and no consumer metadata.
// Close it after its writers finish; open readers own their file handles.
type Store struct {
root *os.Root
directory string
}
// New rejects a symlink store root and creates private object and staging namespaces.
func New(path string) (*Store, error) {
if err := fsutil.MkdirAllPrivate(path); err != nil {
return nil, fmt.Errorf("create cas root: %w", err)
}
info, err := os.Lstat(path)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.Join(fmt.Errorf("cas root must be a regular directory"), err)
}
root, err := os.OpenRoot(path)
if err != nil {
return nil, fmt.Errorf("open cas root: %w", err)
}
for _, name := range []string{"objects", "objects/sha256", "tmp"} {
if err := root.Mkdir(name, 0o700); err != nil && !errors.Is(err, os.ErrExist) {
return nil, errors.Join(err, root.Close())
}
info, err := root.Lstat(name)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, errors.Join(fmt.Errorf("invalid cas namespace %s", name), err, root.Close())
}
}
return &Store{root: root, directory: path}, nil
}
func (s *Store) Close() error {
return s.root.Close()
}
// Path reports the filesystem path of a published object. It does not check
// that the object exists; callers open it through their own filesystem view.
func (s *Store) Path(digest Digest) string {
return filepath.Join(s.directory, filepath.FromSlash(objectPath(digest)))
}
func (s *Store) lock() (*fsutil.FileLock, error) {
if info, err := s.root.Lstat("cas.lock"); err == nil && !info.Mode().IsRegular() {
return nil, fmt.Errorf("cas lock must be a regular file")
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
file, err := s.root.OpenFile("cas.lock", os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
return fsutil.LockOpenedFile(file, true)
}
func objectPath(digest Digest) string {
hex := digest.Hex()
return "objects/sha256/" + hex[:2] + "/" + hex
}
// Open verifies the digest as the stream reaches EOF. Discard copied bytes on error.
func (s *Store) Open(digest Digest) (io.ReadCloser, error) {
name := objectPath(digest)
info, err := s.root.Lstat(name)
if err != nil {
return nil, fmt.Errorf("open cas object: %w", err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%w: object is not a regular file", ErrCorrupt)
}
file, err := s.root.Open(name)
if err != nil {
return nil, errors.Join(ErrRead, err)
}
return &verifiedReader{
file: file,
expected: digest,
hash: sha256.New(),
}, nil
}
// Put computes the address while writing and never publishes a failed stream.
func (s *Store) Put(reader io.Reader, expected *Digest) (object Object, resultErr error) {
writer, err := s.NewWriter()
if err != nil {
return object, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Abort()) }()
if _, err := io.Copy(writer, reader); err != nil {
return object, fmt.Errorf("ingest cas contents: %w", err)
}
return writer.Commit(expected)
}
// Walk visits physical objects once. Consumers must coordinate retention with their metadata.
func (s *Store) Walk(visit func(Object) error) error {
return fs.WalkDir(s.root.FS(), "objects/sha256", func(name string, entry fs.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return err
}
if !entry.Type().IsRegular() {
return nil
}
digest, err := ParseDigest("sha256:" + filepath.Base(name))
if err != nil || filepath.ToSlash(name) != objectPath(digest) {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
return visit(Object{Digest: digest, Size: info.Size()})
})
}
// Remove is for consumer-approved reclamation, not automatic retention.
// Readers already opened on systems that allow to unlink can finish reading their handles.
func (s *Store) Remove(digest Digest) (size int64, resultErr error) {
lock, err := s.lock()
if err != nil {
return 0, err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
return s.removeLocked(digest)
}
func (s *Store) removeLocked(digest Digest) (int64, error) {
name := objectPath(digest)
info, err := s.root.Lstat(name)
if errors.Is(err, os.ErrNotExist) {
return 0, nil
}
if err != nil {
return 0, err
}
if !info.Mode().IsRegular() {
return 0, fmt.Errorf("refuse to remove non-regular cas object")
}
garbage := "tmp/gc-" + rand.Text()
if err := fsutil.RenameRoot(s.root, name, garbage); err != nil {
return 0, err
}
if err := s.removePrivateFile(garbage); err != nil {
return 0, fmt.Errorf("remove reclaimed cas contents: %w", err)
}
return info.Size(), nil
}
// Sweep holds the store lock while plan determines the retained digests and
// locks the state those references came from, then removes every other object.
// No writer can publish from the moment the lock is acquired through the last
// removal, so an object published by an operation that started after plan has
// not run cannot be collected. plan may return a release function for state it
// locked; Sweep runs it before releasing the store lock. A nil reference set is
// rejected rather than read as "delete all", and a plan or enumeration error
// removes nothing.
func (s *Store) Sweep(plan func() (map[Digest]struct{}, func() error, error)) (removed int, bytes int64, resultErr error) {
if plan == nil {
return 0, 0, fmt.Errorf("sweep requires a plan")
}
lock, err := s.lock()
if err != nil {
return 0, 0, err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
referenced, release, err := plan()
if release != nil {
defer func() { resultErr = errors.Join(resultErr, release()) }()
}
if err != nil {
return 0, 0, err
}
if referenced == nil {
return 0, 0, fmt.Errorf("sweep requires a reference set")
}
var objects []Object
if err := s.Walk(func(object Object) error {
objects = append(objects, object)
return nil
}); err != nil {
return 0, 0, err
}
var failures []error
for _, object := range objects {
if _, kept := referenced[object.Digest]; kept {
continue
}
size, err := s.removeLocked(object.Digest)
if err != nil {
failures = append(failures, err)
continue
}
removed++
bytes += size
}
return removed, bytes, errors.Join(failures...)
}
// Cleanup removes only stages whose lease is no longer held by a writer.
func (s *Store) Cleanup() (resultErr error) {
lock, err := s.lock()
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
directory, err := s.root.Open("tmp")
if err != nil {
return err
}
entries, err := directory.ReadDir(-1)
if closeErr := directory.Close(); err != nil || closeErr != nil {
return errors.Join(err, closeErr)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "gc-") && entry.Type().IsRegular() {
if err := s.removePrivateFile("tmp/" + entry.Name()); err != nil {
return err
}
continue
}
if strings.HasPrefix(entry.Name(), "stage-") && strings.HasSuffix(entry.Name(), ".data") {
dataName := "tmp/" + entry.Name()
leaseName := strings.TrimSuffix(dataName, ".data") + ".lease"
if _, err := s.root.Lstat(leaseName); errors.Is(err, os.ErrNotExist) {
if err := s.removePrivateFile(dataName); err != nil {
return err
}
} else if err != nil {
return err
}
continue
}
if !strings.HasPrefix(entry.Name(), "stage-") || !strings.HasSuffix(entry.Name(), ".lease") {
continue
}
if !entry.Type().IsRegular() {
continue
}
leaseName := "tmp/" + entry.Name()
file, err := s.root.OpenFile(leaseName, os.O_RDWR, 0)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
lease, err := fsutil.LockOpenedFile(file, false)
if errors.Is(err, fsutil.ErrLocked) {
continue
}
if err != nil {
return err
}
if err := lease.Close(); err != nil {
return err
}
dataName := strings.TrimSuffix(leaseName, ".lease") + ".data"
for _, name := range []string{dataName, leaseName} {
if err := s.removePrivateFile(name); err != nil {
return err
}
}
}
return nil
}
func (s *Store) removePrivateFile(name string) error {
info, err := s.root.Lstat(name)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("refuse non-regular cas cleanup path")
}
if err := s.root.Chmod(name, 0o600); err != nil {
return err
}
return s.root.Remove(name)
}
package cas
import (
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"github.com/mclucy/lucy/internal/fsutil"
)
type writerState uint8
const (
writerOpen writerState = iota
writerSealed
writerTransferred
writerClosedStage
writerFinished
)
// Writer has one owner. Seal prevents further writes; Abort releases an unpublished stage.
type Writer struct {
store *Store
file *os.File
lease *fsutil.FileLock
dataName string
leaseName string
hash hash.Hash
size int64
state writerState
object Object
}
// NewWriter leases a unique stage so cleanup cannot remove active writes.
func (s *Store) NewWriter() (*Writer, error) {
return s.newWriter(sha256.New())
}
func (s *Store) newWriter(hasher hash.Hash) (writer *Writer, resultErr error) {
lock, err := s.lock()
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
name := "tmp/stage-" + rand.Text()
leaseName := name + ".lease"
leaseFile, err := s.root.OpenFile(leaseName, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
if err != nil {
return nil, err
}
lease, err := fsutil.LockOpenedFile(leaseFile, false)
if err != nil {
return nil, errors.Join(err, s.root.Remove(leaseName))
}
file, err := s.root.OpenFile(name+".data", os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
if err != nil {
return nil, errors.Join(err, lease.Close(), s.root.Remove(leaseName))
}
return &Writer{store: s, file: file, lease: lease, dataName: name + ".data", leaseName: leaseName, hash: hasher}, nil
}
func (w *Writer) Write(data []byte) (int, error) {
if w.state != writerOpen {
return 0, fmt.Errorf("cas writer is closed for writes")
}
count, err := w.file.Write(data)
if count > 0 {
if _, hashErr := w.hash.Write(data[:count]); hashErr != nil {
return count, errors.Join(err, hashErr)
}
w.size += int64(count)
}
return count, err
}
// Seal checks an optional expected digest and makes the stage readable without publishing it.
func (w *Writer) Seal(expected *Digest) (Object, error) {
if w.state != writerOpen && w.state != writerSealed {
return Object{}, os.ErrClosed
}
if w.state == writerOpen {
var digest Digest
w.hash.Sum(digest[:0])
w.object = Object{Digest: digest, Size: w.size}
if err := w.file.Sync(); err != nil {
return Object{}, fmt.Errorf("sync cas stage: %w", err)
}
w.state = writerSealed
}
if expected != nil && w.object.Digest != *expected {
return Object{}, fmt.Errorf("%w: expected %s, got %s", ErrCorrupt, expected.String(), w.object.Digest.String())
}
return w.object, nil
}
// Rewind is available only after Seal; reading does not hash or copy the stage again.
func (w *Writer) Rewind() error {
if w.state != writerSealed && w.state != writerTransferred {
return fmt.Errorf("cas stage must be sealed before reading")
}
_, err := w.file.Seek(0, io.SeekStart)
return err
}
func (w *Writer) Read(data []byte) (int, error) {
if w.state != writerSealed && w.state != writerTransferred {
return 0, fmt.Errorf("cas stage must be sealed before reading")
}
return w.file.Read(data)
}
// Commit publishes only the sealed contents. It can repair corrupt bytes at the same address.
func (w *Writer) Commit(expected *Digest) (object Object, resultErr error) {
object, err := w.Seal(expected)
if err != nil {
return Object{}, err
}
lock, err := w.store.lock()
if err != nil {
return Object{}, err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
name := objectPath(object.Digest)
if info, err := w.store.root.Lstat(name); err == nil {
if !info.Mode().IsRegular() {
return Object{}, fmt.Errorf("invalid existing cas object")
}
} else if !errors.Is(err, os.ErrNotExist) {
return Object{}, err
}
if err := w.store.root.MkdirAll(filepath.Dir(name), 0o700); err != nil {
return Object{}, err
}
if err := w.file.Chmod(0o400); err != nil {
return Object{}, err
}
if err := w.file.Sync(); err != nil {
return Object{}, err
}
w.state = writerClosedStage
if err := w.file.Close(); err != nil {
w.file = nil
return Object{}, err
}
w.file = nil
if err := fsutil.RenameRoot(w.store.root, w.dataName, name); err != nil {
return Object{}, fmt.Errorf("publish cas object: %w", err)
}
return object, w.cleanup()
}
// Abort is idempotent, including after a successful Commit.
func (w *Writer) Abort() (resultErr error) {
if w.state == writerFinished {
return nil
}
lock, err := w.store.lock()
if err != nil {
return errors.Join(err, w.closeFiles())
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
return w.cleanup()
}
func (w *Writer) closeFiles() error {
w.state = writerFinished
var result error
if w.file != nil {
result = w.file.Close()
w.file = nil
}
result = errors.Join(result, w.lease.Close())
w.lease = nil
return result
}
func (w *Writer) cleanup() error {
result := w.closeFiles()
if err := w.store.removePrivateFile(w.dataName); err != nil {
return errors.Join(result, err)
}
return errors.Join(result, w.store.removePrivateFile(w.leaseName))
}
// MoveTo preserves a written prefix and its hash state when staging must change filesystems.
// The original writer becomes read-only and must still be aborted by its owner.
func (w *Writer) MoveTo(destination *Store) (replacement *Writer, resultErr error) {
if (w.state != writerOpen && w.state != writerSealed) || destination == w.store {
return nil, fmt.Errorf("cas writer cannot move to that store")
}
replacement, err := destination.newWriter(w.hash)
if err != nil {
return nil, err
}
staged := replacement
complete := false
defer func() {
if !complete {
resultErr = errors.Join(resultErr, staged.Abort())
}
}()
if _, err := w.file.Seek(0, io.SeekStart); err != nil {
return nil, err
}
count, err := io.Copy(replacement.file, w.file)
if err != nil || count != w.size {
return nil, errors.Join(fmt.Errorf("cannot preserve cas stage prefix"), err)
}
var digest Digest
w.hash.Sum(digest[:0])
w.object = Object{Digest: digest, Size: w.size}
w.state = writerTransferred
replacement.size = w.size
complete = true
return replacement, nil
}
// DetachHash transfers a partial stream's hash state for a consumer's memory-only fallback.
// The original writer can only be aborted after this call.
func (w *Writer) DetachHash() (hash.Hash, error) {
if w.state != writerOpen && w.state != writerSealed {
return nil, os.ErrClosed
}
w.state = writerTransferred
return w.hash, nil
}
package catalog
import "github.com/mclucy/lucy/version"
// DependencyOrigin identifies how a dependency is satisfied.
type DependencyOrigin string
const (
DependencyRegular DependencyOrigin = "regular"
DependencyServerProvided DependencyOrigin = "server-provided"
DependencyEmbedded DependencyOrigin = "embedded"
)
func NormalizeDependencyOrigin(origin DependencyOrigin) DependencyOrigin {
if origin == "" {
return DependencyRegular
}
return origin
}
func (d Dependency) IsServerProvided() bool {
return NormalizeDependencyOrigin(d.Origin) == DependencyServerProvided
}
func (d Dependency) IsEmbedded() bool {
return NormalizeDependencyOrigin(d.Origin) == DependencyEmbedded
}
// Dependency is a package dependency request. Constraint is a 2D array whose
// outer array is OR and inner array is AND; nil or empty means no constraint.
// ID.Version is always empty because Constraint carries the version selector.
//
// DependencyOrigin records how the dependency is satisfied. Regular
// dependencies resolve through upstream providers, server-provided
// dependencies come from the server environment, and embedded dependencies
// are physically bundled inside the parent artifact.
type Dependency struct {
ID VersionedRef
Constraint version.Expr
Mandatory bool
Origin DependencyOrigin
}
package catalog
import "strings"
// DependencyKind is the relationship declared by a descriptor or provider.
type DependencyKind string
const (
DependencyKindUnknown DependencyKind = ""
DependencyKindRequired DependencyKind = "required"
DependencyKindOptional DependencyKind = "optional"
DependencyKindRecommended DependencyKind = "recommended"
DependencyKindSuggested DependencyKind = "suggested"
DependencyKindIncompatible DependencyKind = "incompatible"
DependencyKindConflict DependencyKind = "conflict"
DependencyKindPython DependencyKind = "python"
)
func ParseDependencyKind(s string) DependencyKind {
switch strings.ToLower(strings.TrimSpace(s)) {
case "required", "mandatory":
return DependencyKindRequired
case "optional", "recommends", "recommended":
return DependencyKindOptional
case "suggested", "suggests":
return DependencyKindSuggested
case "incompatible", "breaks":
return DependencyKindIncompatible
case "conflict", "conflicts", "discouraged":
return DependencyKindConflict
case "python":
return DependencyKindPython
default:
return DependencyKindUnknown
}
}
// Package catalog describes package identities, artifacts, compatibility,
// metadata, and dependencies independently of their source.
//
// Runtime observations, persisted workspace state, provider clients, and
// presentation belong to their owning packages, not catalog.
package catalog
import "strings"
// Ecosystem identifies the compatibility surface a server can serve. It is a
// capability descriptor, not the server product itself.
type Ecosystem string
const (
EcosystemUnspecified Ecosystem = ""
EcosystemMinecraft Ecosystem = "minecraft"
EcosystemVanilla = EcosystemMinecraft
EcosystemFabric Ecosystem = "fabric"
EcosystemForge Ecosystem = "forge"
EcosystemNeoForge Ecosystem = "neoforge"
EcosystemBukkit Ecosystem = "bukkit"
EcosystemPaper Ecosystem = "paper"
EcosystemBungeeCord Ecosystem = "bungeecord"
EcosystemVelocity Ecosystem = "velocity"
EcosystemSponge Ecosystem = "sponge"
EcosystemMCDR Ecosystem = "mcdr"
)
func (e Ecosystem) Title() string {
switch e {
case EcosystemUnspecified:
return "Any"
case EcosystemMinecraft:
return "Minecraft"
case EcosystemFabric:
return "Fabric"
case EcosystemForge:
return "Forge"
case EcosystemNeoForge:
return "NeoForge"
case EcosystemBukkit:
return "Bukkit"
case EcosystemPaper:
return "Paper"
case EcosystemBungeeCord:
return "BungeeCord"
case EcosystemVelocity:
return "Velocity"
case EcosystemSponge:
return "Sponge"
case EcosystemMCDR:
return "MCDR"
default:
return "Unknown"
}
}
func (e Ecosystem) String() string {
if e == EcosystemUnspecified {
return "any"
}
return string(e)
}
// Valid reports whether e is a defined ecosystem.
func (e Ecosystem) Valid() bool {
switch e {
case EcosystemUnspecified,
EcosystemMinecraft,
EcosystemFabric,
EcosystemForge,
EcosystemNeoForge,
EcosystemBukkit,
EcosystemPaper,
EcosystemBungeeCord,
EcosystemVelocity,
EcosystemSponge,
EcosystemMCDR:
return true
default:
return false
}
}
func (e Ecosystem) IsSearchable() bool {
switch e {
case EcosystemFabric, EcosystemForge, EcosystemNeoForge,
EcosystemBukkit, EcosystemPaper:
return true
default:
return false
}
}
// Satisfy reports whether e satisfies the required ecosystem. An unspecified
// requirement accepts any ecosystem. Paper satisfies Bukkit because Paper is
// a Bukkit superset; the reverse is not true.
func (e Ecosystem) Satisfy(required Ecosystem) bool {
if required == EcosystemUnspecified {
return true
}
if e == EcosystemUnspecified {
return false
}
if required == EcosystemBukkit && e == EcosystemPaper {
return true
}
return e == required
}
func (e Ecosystem) IsModding() bool {
return e == EcosystemFabric || e == EcosystemForge || e == EcosystemNeoForge
}
// IsUnspecified reports whether the ecosystem is waiting for workspace
// context.
func (e Ecosystem) IsUnspecified() bool {
return e == EcosystemUnspecified
}
// PythonName formats the name as a Python distribution name.
func (n Name) PythonName() string {
return strings.ReplaceAll(string(n), "-", "_")
}
package catalog
// Metadata contains informational data about a package. Archive readers and
// upstream providers both produce it. It carries content only. The source of
// the data is part of the result types, for example upstream.Info.
type Metadata struct {
Title string `json:"title"`
Brief string `json:"brief,omitempty"`
Description string `json:"description,omitempty"`
DescriptionURL string `json:"description_url,omitempty"`
DescriptionIsMarkdown bool `json:"description_is_markdown,omitempty"`
Authors []Person `json:"authors,omitempty"`
URLs []URL `json:"urls,omitempty"`
License string `json:"license,omitempty"`
}
type Person struct {
Name string `json:"name"`
Role string `json:"role,omitempty"`
URL string `json:"url,omitempty"`
Email string `json:"email,omitempty"`
}
type URL struct {
Name string `json:"name"`
Type URLType `json:"type"`
URL string `json:"url"`
}
func (p URLType) String() string {
switch p {
case URLFile:
return "File"
case URLHome:
return "Homepage"
case URLSource:
return "Source"
case URLWiki:
return "Wiki"
case URLForum:
return "Forum"
case URLIssues:
return "Issues"
case URLSponsor:
return "Sponsor"
case URLMisc:
return "URL"
default:
return "Unknown"
}
}
type URLType uint8
const (
URLFile URLType = iota
URLHome
URLSource
URLWiki
URLForum
URLIssues
URLSponsor
URLMisc
)
package catalog
import "strings"
// Provider identifies the execution layer that fetches metadata or artifacts.
// A provider may or may not back a Registry.
type Provider string
const (
ProviderUnspecified Provider = ""
ProviderUnknown Provider = "unknown"
ProviderModrinth Provider = "modrinth"
ProviderCurseForge Provider = "curseforge"
ProviderHangar Provider = "hangar"
ProviderSpiget Provider = "spiget"
ProviderMCDR Provider = "mcdr"
ProviderGitHub Provider = "github"
ProviderMojang Provider = "mojang"
ProviderFabric Provider = "fabric"
ProviderForge Provider = "forge"
ProviderNeoForge Provider = "neoforge"
ProviderBMCLAPI Provider = "bmclapi"
ProviderPaperMC Provider = "papermc"
ProviderPurpur Provider = "purpur"
ProviderMaven Provider = "maven"
ProviderPyPI Provider = "pypi"
)
// String returns the persisted provider key. The unspecified provider is
// rendered as "any" for display only; use the value itself for storage.
func (p Provider) String() string {
if p == ProviderUnspecified {
return "any"
}
return string(p)
}
// Title returns the human-facing provider name.
func (p Provider) Title() string {
switch p {
case ProviderModrinth:
return "Modrinth"
case ProviderCurseForge:
return "CurseForge"
case ProviderHangar:
return "Hangar"
case ProviderSpiget:
return "Spiget"
case ProviderMCDR:
return "MCDR"
case ProviderGitHub:
return "GitHub"
case ProviderMojang:
return "Mojang"
case ProviderFabric:
return "Fabric"
case ProviderForge:
return "Forge"
case ProviderNeoForge:
return "NeoForge"
case ProviderBMCLAPI:
return "BMCLAPI"
case ProviderPaperMC:
return "PaperMC"
case ProviderPurpur:
return "Purpur"
case ProviderMaven:
return "Maven"
case ProviderPyPI:
return "PyPI"
case ProviderUnspecified:
return "Any"
default:
return "Unknown"
}
}
// Registry returns the registry backed by p. Providers such as GitHub, Maven,
// and PyPI are execution layers without a registry.
func (p Provider) Registry() (Registry, bool) {
switch p {
case ProviderModrinth:
return RegistryModrinth, true
case ProviderCurseForge:
return RegistryCurseForge, true
case ProviderHangar:
return RegistryHangar, true
case ProviderSpiget:
return RegistrySpiget, true
case ProviderMCDR:
return RegistryMCDR, true
default:
return RegistryUnspecified, false
}
}
// ParseProvider maps a provider key to its typed value. The empty key means
// the unspecified provider, which lets routing choose.
func ParseProvider(s string) (Provider, bool) {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return ProviderUnspecified, true
}
switch Provider(s) {
case ProviderUnknown,
ProviderModrinth,
ProviderCurseForge,
ProviderHangar,
ProviderSpiget,
ProviderMCDR,
ProviderGitHub,
ProviderMojang,
ProviderFabric,
ProviderForge,
ProviderNeoForge,
ProviderBMCLAPI,
ProviderPaperMC,
ProviderPurpur,
ProviderMaven,
ProviderPyPI:
return Provider(s), true
default:
return ProviderUnknown, false
}
}
package catalog
import "github.com/mclucy/lucy/version"
// Name is a package name. User input may be untrusted; normalize it
// before using it as an identity.
type Name string
func (n Name) String() string {
return string(n)
}
// Ref identifies one package supplied by a provider.
type Ref struct {
Name Name `json:"name" yaml:"name"`
Provider Provider `json:"provider,omitempty" yaml:"provider,omitempty"`
}
func (p Ref) String() string {
return p.StringBase()
}
func (p Ref) StringBase() string {
if p.Provider == ProviderUnspecified {
return p.Name.String()
}
return p.Provider.String() + ":" + p.Name.String()
}
// VersionedRef identifies a package selection for one ecosystem.
// Version may be a selector during resolution or an exact version after
// resolution.
type VersionedRef struct {
Ref `yaml:",inline"`
Ecosystem Ecosystem `json:"ecosystem" yaml:"ecosystem"`
Version version.Version `json:"version" yaml:"version"`
}
func (p VersionedRef) String() string {
suffix := ""
if p.Version != version.Any {
suffix = "@" + p.Version.String()
}
return p.Ref.String() + suffix
}
// StringBase is the stable provider-qualified package identity used by graph
// and provenance keys.
func (p VersionedRef) StringBase() string {
return p.Ref.StringBase()
}
// StringQualified is the human-facing label that also carries the ecosystem.
func (p VersionedRef) StringQualified() string {
return p.Ecosystem.String() + "/" + p.StringBase() + "@" + p.Version.String()
}
package catalog
import "strings"
// Registry identifies a package repository with search and package metadata.
// A registry is executed by a Provider.
type Registry string
const (
RegistryUnspecified Registry = ""
RegistryUnknown Registry = "unknown"
RegistryModrinth Registry = "modrinth"
RegistryCurseForge Registry = "curseforge"
RegistryHangar Registry = "hangar"
RegistrySpiget Registry = "spiget"
RegistryMCDR Registry = "mcdr"
)
func (r Registry) String() string {
if r == RegistryUnspecified {
return "any"
}
return string(r)
}
func (r Registry) Title() string {
switch r {
case RegistryModrinth:
return "Modrinth"
case RegistryCurseForge:
return "CurseForge"
case RegistryHangar:
return "Hangar"
case RegistrySpiget:
return "Spiget"
case RegistryMCDR:
return "MCDR"
case RegistryUnspecified:
return "Any"
default:
return "Unknown"
}
}
// Provider returns the execution layer that handles r.
func (r Registry) Provider() Provider {
switch r {
case RegistryModrinth:
return ProviderModrinth
case RegistryCurseForge:
return ProviderCurseForge
case RegistryHangar:
return ProviderHangar
case RegistrySpiget:
return ProviderSpiget
case RegistryMCDR:
return ProviderMCDR
default:
return ProviderUnknown
}
}
// ParseRegistry maps a registry key to its typed value. The empty key means
// all registries.
func ParseRegistry(s string) (Registry, bool) {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return RegistryUnspecified, true
}
switch Registry(s) {
case RegistryUnknown,
RegistryModrinth,
RegistryCurseForge,
RegistryHangar,
RegistrySpiget,
RegistryMCDR:
return Registry(s), true
default:
return RegistryUnknown, false
}
}
package catalog
import "strings"
// Side identifies where a descriptor module is intended to run.
type Side string
const (
SideUnknown Side = ""
SideClient Side = "client"
SideServer Side = "server"
SideBoth Side = "both"
)
func ParseSide(s string) Side {
switch strings.ToLower(strings.TrimSpace(s)) {
case "client":
return SideClient
case "server":
return SideServer
case "*", "both", "any":
return SideBoth
default:
return SideUnknown
}
}
// Command cipher generates build-time embedded secret material.
//
// It never prints the plaintext secret. Output is four KEY=value lines for
// shell capture into linker-injected fragments.
//
// The secret is read from stdin only (never from argv), so it does not appear
// in process argument lists.
package main
import (
"encoding/hex"
"flag"
"fmt"
"io"
"os"
"strings"
"github.com/mclucy/lucy/internal/cipher"
)
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s -encrypt-stdin [options] < secret\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\nReads the plaintext secret from stdin, generates a fresh key, encrypts,\n")
fmt.Fprintf(os.Stderr, "and prints four fragment lines:\n")
fmt.Fprintf(os.Stderr, " cipher_key_a, cipher_key_b, cipher_ciphertext_a, cipher_ciphertext_b\n")
fmt.Fprintf(os.Stderr, "\nOptions:\n")
flag.PrintDefaults()
}
encryptStdin := flag.Bool("encrypt-stdin", false, "read plaintext secret from stdin (required)")
version := flag.String("version", "", "release version for AD binding (empty = unbound local)")
commit := flag.String("commit", "", "release commit for AD binding (empty = unbound local)")
flag.Parse()
if !*encryptStdin {
flag.Usage()
os.Exit(2)
}
secret, err := readSecret(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "cipher: read secret: %v\n", err)
os.Exit(1)
}
if secret == "" {
fmt.Fprintf(os.Stderr, "cipher: empty secret on stdin\n")
os.Exit(2)
}
key, err := cipher.GenerateKey()
if err != nil {
fmt.Fprintf(os.Stderr, "cipher: generate key: %v\n", err)
os.Exit(1)
}
ctHex, err := cipher.Encrypt(key, secret, *version, *commit)
if err != nil {
fmt.Fprintf(os.Stderr, "cipher: encrypt: %v\n", err)
os.Exit(1)
}
keyHex := hex.EncodeToString(key)
keyA, keyB, err := splitExactHalves(keyHex)
if err != nil {
fmt.Fprintf(os.Stderr, "cipher: split key: %v\n", err)
os.Exit(1)
}
ctA, ctB, err := splitExactHalves(ctHex)
if err != nil {
fmt.Fprintf(os.Stderr, "cipher: split ciphertext: %v\n", err)
os.Exit(1)
}
// Four fragment lines only — never print the plaintext secret.
fmt.Printf("cipher_key_a=%s\n", keyA)
fmt.Printf("cipher_key_b=%s\n", keyB)
fmt.Printf("cipher_ciphertext_a=%s\n", ctA)
fmt.Printf("cipher_ciphertext_b=%s\n", ctB)
}
func readSecret(r io.Reader) (string, error) {
raw, err := io.ReadAll(r)
if err != nil {
return "", err
}
// Preserve internal content; only strip one trailing \n or \r\n so secrets
// are not altered beyond ordinary line-oriented stdin delivery.
s := string(raw)
s = strings.TrimSuffix(s, "\n")
s = strings.TrimSuffix(s, "\r")
if strings.TrimSpace(s) == "" {
return "", nil
}
return s, nil
}
func splitExactHalves(s string) (string, string, error) {
if len(s) == 0 {
return "", "", fmt.Errorf("empty string")
}
if len(s)%2 != 0 {
return "", "", fmt.Errorf("odd length %d", len(s))
}
mid := len(s) / 2
return s[:mid], s[mid:], nil
}
package cmd
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"path/filepath"
"runtime/debug"
"strings"
"charm.land/fang/v2"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/config"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/internal/agent"
"github.com/mclucy/lucy/internal/cipher"
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/internal/cli/compile"
docscli "github.com/mclucy/lucy/internal/cli/docs"
"github.com/mclucy/lucy/internal/cli/file"
"github.com/mclucy/lucy/internal/cli/info"
"github.com/mclucy/lucy/internal/cli/search"
"github.com/mclucy/lucy/javainstall"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/operation"
"github.com/mclucy/lucy/report"
"github.com/mclucy/lucy/serverregistry"
"github.com/mclucy/lucy/telemetry"
"github.com/mclucy/lucy/terminal/style"
"github.com/mclucy/lucy/upstream/routing"
"github.com/spf13/cobra"
)
var (
version string
commit string
telemetryRelease string
)
const agentInstructions = "Instructions for agents:\n" +
"The --help flag is human-oriented. Use `lucy docs list` to view all available documentation,\n" +
"`lucy docs grep` to search, and `lucy docs read` to read one or more articles. `--json-compact` and\n" +
"`--no-interactive` will be on by default for you. You can use tools like `jq` to process\n" +
"command outputs. Use `lucy schema` to list JSON result contracts, `lucy schema COMMAND` to\n" +
"read a result schema, and `lucy schema --error` to read the shared failure schema.\n" +
"If telemetry is off, a rejected agent report is not an error to repair. Never turn telemetry on\n" +
"without an explicit instruction from the user."
// RunnerOptions supplies the invocation directory, streams, and network transport.
// Directory must be absolute. A nil HTTPClient creates a client owned by the run.
type RunnerOptions struct {
Directory string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
HTTPClient *http.Client
}
// Runner owns one invocation: its command tree, JSON contracts, streams, and
// output state. Construct a runner per run; nothing is shared between runs.
type Runner struct {
root *cobra.Command
contracts *cli.JSONContracts
args []string
stdin io.Reader
stdout io.Writer
stderr io.Writer
executing *cobra.Command
loggingReady bool
selectedJSON cli.JSONRendering
machineResult bytes.Buffer
resultWriter io.Writer
flagParsingFailed bool
agentHelpInjected bool
invocation *cli.Invocation
dependencies *cli.Dependencies
httpClient *http.Client
ownsHTTPClient bool
directory string
telemetry *telemetry.Client
telemetryErr error
telemetryLocal bool
}
// NewRunner prepares an isolated invocation with a fresh command tree, fresh
// JSON contracts, and the given streams.
func NewRunner(args []string, options RunnerOptions) *Runner {
r := &Runner{
contracts: cli.NewJSONContracts(),
args: args,
stdin: options.Stdin,
stdout: options.Stdout,
stderr: options.Stderr,
invocation: cli.NewInvocation(options.Directory, nil),
dependencies: &cli.Dependencies{},
httpClient: options.HTTPClient,
directory: options.Directory,
}
if r.httpClient == nil {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
transport = &http.Transport{Proxy: http.ProxyFromEnvironment}
}
r.httpClient = &http.Client{Transport: transport.Clone()}
r.ownsHTTPClient = true
}
r.root = NewRootCommand(r.contracts, r.invocation, r.dependencies)
r.registerTelemetryCommands()
r.root.SetArgs(r.args)
r.root.SetIn(r.stdin)
r.root.SetOut(r.stdout)
r.root.SetErr(r.stderr)
r.root.PersistentPreRunE = r.persistentPreRunE
return r
}
// NewRootCommand builds a fresh root with its persistent flags and
// subcommands bound to contracts. Agent defaults are applied per run, not
// here.
func NewRootCommand(contracts *cli.JSONContracts, invocation *cli.Invocation, dependencies *cli.Dependencies) *cobra.Command {
root := &cobra.Command{
Use: "lucy",
Short: "The Minecraft server package manager",
}
cli.RegisterServerSelection(root)
root.PersistentFlags().String(
cli.FlagLogLevel,
"info",
"Level mirrored by --print-logs; the log file always records debug",
)
root.PersistentFlags().String(
cli.FlagLogFile,
log.DefaultFile(),
"Write diagnostic logs to this file",
)
root.PersistentFlags().Bool(
cli.FlagPrintLogs,
false,
"Mirror diagnostic logs to stderr; machine output keeps stderr clean",
)
root.PersistentFlags().Bool(
cli.FlagJSON,
false,
"Print JSON indented with 2 spaces",
)
root.PersistentFlags().Bool(
cli.FlagJSONCompact,
false,
"Print JSON without indentation; wins over --json",
)
root.PersistentFlags().Bool(
cli.FlagNoColor,
false,
"Disable colored output",
)
root.PersistentFlags().Bool(
cli.FlagNoInteractive,
false,
"Disable prompts and animated output; fail instead of asking",
)
root.PersistentFlags().Bool(
cli.FlagNoStyle,
false,
"Same as --no-color --no-interactive",
)
docs := docscli.NewCommand()
contracts.Ignore(docs)
root.AddCommand(
cli.NewAddCommand(contracts, invocation, dependencies),
cli.NewBisectCommand(contracts, invocation, dependencies),
cli.NewCacheCommand(contracts, invocation, dependencies),
cli.NewConfigCommand(contracts, invocation, dependencies),
cli.NewCreateCommand(contracts, invocation, dependencies),
cli.NewDeinitCommand(contracts, invocation, dependencies),
docs,
cli.NewDoctorCommand(),
file.NewCommand(contracts, invocation, dependencies),
cli.NewExportCommand(invocation),
cli.NewInitCommand(contracts, invocation, dependencies),
cli.NewInstallCommand(contracts, invocation, dependencies),
cli.NewJavaCommand(contracts, invocation, dependencies),
cli.NewLeavesCommand(contracts, invocation, dependencies),
cli.NewRemoveCommand(contracts, invocation, dependencies),
cli.NewServerCommand(contracts, invocation, dependencies),
cli.NewStoreCommand(contracts, invocation, dependencies),
cli.NewTreeCommand(contracts, invocation, dependencies),
cli.NewUpgradeCommand(contracts, invocation, dependencies),
compile.NewCommand(invocation),
info.NewCommand(contracts, invocation, dependencies),
cli.NewStatusCommand(contracts, invocation, dependencies),
search.NewCommand(contracts, invocation, dependencies),
cli.NewSchemaCommand(contracts, invocation),
)
return root
}
func (r *Runner) persistentPreRunE(cmd *cobra.Command, args []string) error {
r.executing = cmd
mode := r.effectiveRendering(cmd)
r.selectedJSON = mode
r.configurePresentation(cmd)
if !filepath.IsAbs(r.directory) {
return fmt.Errorf("invocation directory must be absolute")
}
if isTelemetryCommand(cmd) {
return nil
}
if err := r.configureLogging(cmd); err != nil {
return err
}
r.loggingReady = true
if err := r.configureServices(cmd); err != nil {
return err
}
if n := cmd.Name(); n != "__complete" && n != "__completeNoDesc" {
r.invocation.Logger.Info("action invoked", "action", cmd.CommandPath(), "directory", r.invocation.Directory)
if mode == cli.JSONNone && !r.contracts.Ignores(cmd) && r.invocation.TelemetryEnabled {
r.announceTelemetry(cmd)
}
}
return nil
}
// announceTelemetry presents the one-time telemetry notice. The claim is
// recorded before the notice renders, so a machine presents it once even when
// several invocations start together, and a claim that cannot render stays
// claimed.
func (r *Runner) announceTelemetry(cmd *cobra.Command) {
if r.telemetry == nil || r.telemetryErr != nil {
return
}
claimed, err := r.telemetry.ClaimNotice()
if err != nil {
r.invocation.Logger.Debug("telemetry notice was not shown", "error", err)
return
}
if !claimed {
return
}
identity := "lucy"
if displayVersion, _ := buildVersion(); displayVersion != "" {
identity += " " + displayVersion
}
report.New(cmd.ErrOrStderr(), r.invocation.Theme).Info(
identity + " comes with opt-out telemetry. You can manage it with `lucy telemetry`. " +
"If you are using an agent with telemetry on, they will be instructed to write " +
"reports autonomously upon failure or difficulties. Your data will be used for " +
"improving lucy only. See <lucy.lc/docs/commands/telemetry> for more info.",
)
}
func (r *Runner) configurePresentation(cmd *cobra.Command) {
noStyle, _ := cmd.Flags().GetBool(cli.FlagNoStyle)
noColor, _ := cmd.Flags().GetBool(cli.FlagNoColor)
noInteractive, _ := cmd.Flags().GetBool(cli.FlagNoInteractive)
r.invocation.Theme = style.New(!noStyle && !noColor)
r.invocation.Interactive = r.effectiveRendering(cmd) == cli.JSONNone && !noStyle && !noInteractive
if r.effectiveRendering(cmd) == cli.JSONNone {
var input io.Reader
if r.invocation.Interactive {
input = r.stdin
}
r.invocation.Stdout, r.invocation.Stderr = style.DetectTerminals(input, r.stdout, r.stderr)
}
}
func (r *Runner) configureServices(cmd *cobra.Command) error {
registry, registryErr := serverregistry.New()
r.dependencies.Registry = registry
r.dependencies.RegistryError = registryErr
if cmd.Name() != "__complete" && cmd.Name() != "__completeNoDesc" &&
cmd.Root().PersistentFlags().Changed(cli.FlagServer) && registryErr != nil {
return registryErr
}
if err := cli.ResolveServer(cmd, registry, r.invocation); err != nil {
return err
}
settings, err := config.New(r.invocation.Directory, r.invocation.Logger)
if err != nil {
return err
}
proxy, proxyErr := settings.GitHubProxy()
key, keyErr := settings.CurseForgeAPIKey()
if err := errors.Join(proxyErr, keyErr); err != nil {
if !canRepairSettings(cmd) {
return fmt.Errorf("%w\nrun `lucy config` to inspect or repair machine settings", err)
}
r.invocation.Logger.Warn(err)
}
key = strings.TrimSpace(key)
configuredKey := key != ""
if key == "" {
embedded, err := cipher.Decode()
if err != nil {
r.invocation.Logger.Debug("embedded CurseForge key unavailable", "error", err)
} else {
key = strings.TrimSpace(embedded)
}
}
r.invocation.Logger.Debug(
"machine settings applied",
"github_proxy_configured", proxy != "",
"curseforge_key_configured", configuredKey,
)
network := cache.New(cache.DefaultRoot(), cache.DefaultCacheConfig(), r.httpClient, r.invocation.Logger)
router := routing.New(network, r.invocation.Logger, key)
files := filetrack.NewService(r.httpClient, r.invocation.Logger)
installer := install.NewService(
router, network, files, install.NewPythonHost(r.invocation.Directory, r.invocation.Logger), registry, r.invocation.Logger, proxy,
)
r.dependencies.Network = network
r.dependencies.Router = router
r.dependencies.Files = files
r.dependencies.Operations = operation.NewService(installer, router, registry, r.invocation.Logger)
r.dependencies.JavaHost = javainstall.NewHost(network, javainstall.NewSystem(), r.invocation.Logger)
return nil
}
// canRepairSettings reports whether cmd belongs to the surfaces that stay
// runnable while machine settings are unreadable, so the user can inspect and
// repair the configuration that fails the other commands.
func canRepairSettings(cmd *cobra.Command) bool {
for current := cmd; current != nil; current = current.Parent() {
switch current.Name() {
case "config", "docs", "doctor", "schema",
"__complete", "__completeNoDesc":
return true
}
}
return false
}
func (r *Runner) configureLogging(cmd *cobra.Command) error {
flags := cmd.Root().PersistentFlags()
path, err := flags.GetString(cli.FlagLogFile)
if err != nil {
return err
}
level, err := flags.GetString(cli.FlagLogLevel)
if err != nil {
return err
}
printLogs, err := flags.GetBool(cli.FlagPrintLogs)
if err != nil {
return err
}
var mirror io.Writer
if printLogs && r.effectiveRendering(cmd) == cli.JSONNone {
mirror = r.stderr
}
if path != "" && !filepath.IsAbs(path) {
path = filepath.Join(r.directory, path)
}
if err := r.invocation.Logger.Close(); err != nil {
return err
}
r.invocation.Logger, err = log.New(path, level, mirror)
if err == nil && r.telemetry != nil && !isTelemetryCommand(cmd) {
if enabled, stateErr := r.telemetry.Enabled(cmd.Context()); stateErr == nil && enabled {
r.invocation.TelemetryEnabled = true
r.invocation.Logger.CaptureStages()
}
}
return err
}
// applyAgentDefaults makes the non-interactive and compact-JSON behaviors
// implicit for detected agents. Explicit flags still win because parsing runs
// after these defaults are installed.
func (r *Runner) applyAgentDefaults() {
r.setPersistentBoolDefault(cli.FlagNoInteractive, false)
r.setPersistentBoolDefault(cli.FlagJSONCompact, false)
if !agent.Detected() {
return
}
r.setPersistentBoolDefault(cli.FlagNoInteractive, true)
if !r.contracts.Supports(r.invocationCommand()) {
return
}
if cli.JSONFlagsPresent(r.args) {
return
}
r.setPersistentBoolDefault(cli.FlagJSONCompact, true)
}
func (r *Runner) setPersistentBoolDefault(name string, value bool) {
flag := r.root.PersistentFlags().Lookup(name)
if flag == nil {
return
}
text := "false"
if value {
text = "true"
}
_ = flag.Value.Set(text)
flag.DefValue = text
}
// injectAgentInstructions adds the agent notice to every help text. Help is
// rendered from Long, so this works for both fang and Cobra help paths.
func (r *Runner) injectAgentInstructions() {
if r.agentHelpInjected || !agent.Detected() {
return
}
r.agentHelpInjected = true
block := "\n\n" + agentInstructions
if r.telemetry != nil {
if enabled, err := r.telemetry.Enabled(context.Background()); err == nil && enabled {
block += "\nWhen a Lucy command fails, panics, or returns a confusing result, report it once without asking:\n" +
"`lucy agent-report \"<text>\" --agent <your harness> --model <your model>`. Write one to three\n" +
"sentences: the command, the error code or observed behavior, and what you expected. Leave out\n" +
"secrets, absolute paths, logs, transcripts, and user data. Skip failures caused by your own wrong\n" +
"directory, wrong flag, or missing prerequisite; report unexpected behavior even when a workaround\n" +
"works. A rejected report needs no retry."
}
}
var walk func(*cobra.Command)
walk = func(cmd *cobra.Command) {
switch {
case cmd.Long != "":
cmd.Long += block
case cmd.Short != "":
cmd.Long = cmd.Short + block
}
for _, child := range cmd.Commands() {
walk(child)
}
}
walk(r.root)
}
// effectiveRendering reports the rendering requested by the actual
// invocation. Parsed flags win; otherwise the raw command line is inspected,
// so a failure that happens before Cobra parses flags still honors --json and
// --json-compact.
func (r *Runner) effectiveRendering(cmd *cobra.Command) cli.JSONRendering {
if r.contracts.Ignores(cmd) {
return cli.JSONNone
}
mode := cli.JSONModeFromArgs(cmd, r.args)
if cmd.Flags().Parsed() && !r.flagParsingFailed {
mode = cli.JSONMode(cmd)
} else if mode == cli.JSONNone && !cli.JSONFlagsPresent(r.args) {
if compact, _ := cmd.Root().PersistentFlags().GetBool(cli.FlagJSONCompact); compact {
mode = cli.JSONCompact
}
}
if mode == cli.JSONNone && r.contracts.IsNative(cmd) {
return cli.JSONPretty
}
return mode
}
// invocationCommand finds the command the raw command line addresses, even
// when Cobra stopped before recording the executing command.
func (r *Runner) invocationCommand() *cobra.Command {
if r.executing != nil {
return r.executing
}
command, _, _ := r.root.Find(r.args)
if command != nil {
return command
}
return r.root
}
// renderError keeps human failures readable: structured details render their
// own multi-line explanation, ordinary errors render on one line, and usage
// mistakes keep the framework's format with its --help hint. Machine mode
// defers rendering to the CLI boundary so the log file can close first and a
// close failure joins the same document.
func (r *Runner) renderError(w io.Writer, styles fang.Styles, err error) {
if err == nil {
return
}
var exitErr *cli.ExitError
if errors.As(err, &exitErr) && exitErr.Silent {
return
}
command := r.invocationCommand()
if mode := r.effectiveRendering(command); mode != cli.JSONNone {
r.selectedJSON = mode
return
}
var detail *report.Detail
if errors.As(err, &detail) && detail != nil {
report.RenderDetail(w, detail, r.invocation.Theme)
r.writeLogPath(w)
return
}
if cli.IsFrameworkUsageError(err) {
fang.DefaultErrorHandler(w, styles, err)
// The framework names the rejected flag but never what this command
// accepts, which is the fact the caller needs to retry.
if hint := cli.UsageHint(command, err); hint != "" {
fmt.Fprintf(w, " %s %s\n", r.invocation.Theme.Note("hint:"), hint)
}
return
}
report.New(w, r.invocation.Theme).Error(err)
r.writeLogPath(w)
}
func (r *Runner) writeLogPath(w io.Writer) {
if path := r.invocation.Logger.FilePath(); path != "" {
fmt.Fprintf(w, " %s %s\n", r.invocation.Theme.Muted("log:"), path)
}
}
// Close releases the invocation's diagnostics and owned HTTP connections.
func (r *Runner) Close() error {
var cacheErr error
if r.dependencies.Network != nil {
cacheErr = r.dependencies.Network.Close()
}
if r.ownsHTTPClient {
r.httpClient.CloseIdleConnections()
}
return errors.Join(cacheErr, r.invocation.Logger.Close())
}
// WriteError renders a failure through the same mode-aware funnel outside
// command execution. main uses it for a log file that fails to close.
func (r *Runner) WriteError(err error) {
if err == nil {
return
}
command := r.invocationCommand()
if mode := r.effectiveRendering(command); mode != cli.JSONNone {
r.selectedJSON = mode
}
output := cli.NewOperationOutput(command, r.invocation)
output.SetJSONRendering(r.selectedJSON)
output.WriteError(err, r.invocation.Logger.FilePath())
}
// JSONSelected reports whether the actual invocation selected machine output.
func (r *Runner) JSONSelected() bool {
return r.selectedJSON != cli.JSONNone
}
// FlushOutput publishes a machine result only after command cleanup succeeds.
func (r *Runner) FlushOutput() error {
if !r.JSONSelected() || r.resultWriter == nil {
return nil
}
if _, err := r.machineResult.WriteTo(r.resultWriter); err != nil {
return fmt.Errorf("write output: %w", err)
}
return nil
}
// panicError reports an unexpected panic through the ordinary failure
// funnel. The message stays stable for machine output; the stack goes to the
// diagnostic log.
type panicError struct {
value any
stack []byte
}
func (e *panicError) Error() string {
return fmt.Sprintf("internal error: %v", e.value)
}
// Execute runs the invocation. Per-run state is reset first, so a runner
// always starts from a clean parse result and output buffer.
func (r *Runner) Execute(ctx context.Context) (err error) {
started := r.startTelemetry()
defer func() { r.finishTelemetry(started, err) }()
defer func() {
recovered := recover()
if recovered == nil {
return
}
stack := debug.Stack()
err = &panicError{value: recovered, stack: stack}
path := r.root.CommandPath()
if r.executing != nil {
path = r.executing.CommandPath()
}
r.invocation.Logger.Error(fmt.Errorf("%s panicked: %v\n%s", path, recovered, stack))
}()
displayVersion, displayCommit := buildVersion()
r.executing = nil
r.loggingReady = false
r.flagParsingFailed = false
r.applyAgentDefaults()
r.injectAgentInstructions()
command := r.invocationCommand()
r.selectedJSON = r.effectiveRendering(command)
r.root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
r.flagParsingFailed = true
return err
})
r.machineResult.Reset()
r.resultWriter = nil
if r.JSONSelected() {
r.resultWriter = r.root.OutOrStdout()
r.root.SetOut(&r.machineResult)
defer r.root.SetOut(r.resultWriter)
}
if r.JSONSelected() {
// Fang probes terminal colors before invoking its error handler.
// Machine mode must not emit those queries on the real stdout.
r.root.SilenceErrors = true
r.root.SilenceUsage = true
r.root.Version = displayVersion
_, err = r.root.ExecuteContextC(ctx)
} else {
err = fang.Execute(
ctx,
r.root,
fang.WithVersion(displayVersion),
fang.WithCommit(displayCommit),
fang.WithErrorHandler(r.renderError),
)
}
if err != nil {
command := r.executing
if command == nil {
var findErr error
command, _, findErr = r.root.Find(r.args)
if command == nil {
command = r.root
}
if findErr != nil {
// Unknown commands fail before Cobra parses the persistent
// flags. Keep the flag failure for the log without rendering a
// second human error.
if flagErr := r.root.ParseFlags(r.args); flagErr != nil {
err = errors.Join(err, flagErr)
}
}
if configureErr := r.configureLogging(command); configureErr != nil {
if !r.JSONSelected() {
report.New(r.stderr, r.invocation.Theme).Error(configureErr)
}
return errors.Join(err, configureErr)
}
r.loggingReady = true
}
if r.loggingReady {
var exitErr *cli.ExitError
if !errors.As(err, &exitErr) || !exitErr.Silent {
r.invocation.Logger.Error(fmt.Errorf("%s failed: %w", command.CommandPath(), err))
}
}
return err
}
if r.loggingReady && r.executing != nil {
if n := r.executing.Name(); n != "__complete" && n != "__completeNoDesc" {
r.invocation.Logger.Info("action completed", "action", r.executing.CommandPath())
}
}
return nil
}
package cmd
import (
"context"
"errors"
"fmt"
"os"
"runtime"
"strings"
"time"
"github.com/mclucy/lucy/internal/agent"
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/report"
"github.com/mclucy/lucy/telemetry"
"github.com/spf13/cobra"
)
var (
telemetryStatsJSON cli.JSONResult[*telemetry.Stats]
telemetryActionJSON cli.JSONResult[*telemetryAction]
)
type telemetryAction struct {
Action string `json:"action"`
State string `json:"state"`
RecordID string `json:"record_id,omitempty"`
Message string `json:"message,omitempty"`
}
func (r *Runner) registerTelemetryCommands() {
status := &cobra.Command{
Use: "telemetry", Short: "Control telemetry and inspect local statistics", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { return r.telemetryStats(cmd) },
}
stats := &cobra.Command{
Use: "stats", Short: "Show local telemetry upload statistics", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { return r.telemetryStats(cmd) },
}
telemetryStatsJSON.Bind(r.contracts, status)
telemetryStatsJSON.Bind(r.contracts, stats)
status.AddCommand(stats)
for _, setting := range []struct {
name string
enabled bool
}{{"on", true}, {"off", false}} {
command := &cobra.Command{
Use: setting.name, Short: "Turn telemetry " + setting.name, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := r.requireTelemetry(); err != nil {
return err
}
if err := r.telemetry.SetEnabled(cmd.Context(), setting.enabled); err != nil {
return err
}
message := "Telemetry is enabled."
state := setting.name
if !setting.enabled {
message = "Telemetry is off; pending local records were deleted. Existing remote data was not changed."
} else if enabled, err := r.telemetry.Enabled(cmd.Context()); err != nil {
return err
} else if !enabled {
state = "off"
if r.telemetryLocal {
message = "The saved preference is on, but telemetry is disabled in local development builds."
} else {
message = "The saved preference is on, but telemetry remains off due to the environment."
}
}
return r.writeTelemetryAction(cmd, &telemetryAction{Action: setting.name, State: state, Message: message})
},
}
telemetryActionJSON.Bind(r.contracts, command)
status.AddCommand(command)
}
reset := &cobra.Command{
Use: "reset-id", Short: "Reset the client ID and local statistics", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := r.requireTelemetry(); err != nil {
return err
}
if _, err := r.telemetry.ResetIdentity(cmd.Context()); err != nil {
return err
}
return r.writeTelemetryAction(cmd, &telemetryAction{Action: "reset-id", State: "reset", Message: "The client ID and local statistics were reset. Existing remote data was not changed."})
},
}
telemetryActionJSON.Bind(r.contracts, reset)
status.AddCommand(reset)
agentReport := &cobra.Command{
Use: "agent-report [CONTENT]", Short: "Submit an agent report", Hidden: true, Args: cobra.ExactArgs(1),
Long: "Submit one privacy-filtered report about a Lucy failure or friction in this session.\n" +
"The argument is the report body. Write one to three sentences: the command, the error code or\n" +
"observed behavior, and what you expected instead. Add --agent and --model when you know them.\n" +
"Leave out secrets, absolute paths, logs, transcripts, and user data; Lucy also redacts common\n" +
"patterns before upload. Skip failures caused by your own wrong directory, wrong flag, or missing\n" +
"prerequisite; report unexpected behavior even when a workaround works. Report each distinct\n" +
"problem once. The result is queued, accepted, or rejected, and a rejected report needs no repair.",
RunE: func(cmd *cobra.Command, args []string) error { return r.submitAgentReport(cmd, args[0]) },
}
agentReport.Flags().String("agent", "", "Agent name")
agentReport.Flags().String("model", "", "Model name")
telemetryActionJSON.Bind(r.contracts, agentReport)
r.root.AddCommand(status, agentReport)
}
func (r *Runner) requireTelemetry() error {
if r.telemetryErr != nil {
return fmt.Errorf("telemetry state: %w", r.telemetryErr)
}
if r.telemetry == nil {
return errors.New("telemetry state is unavailable")
}
return nil
}
func (r *Runner) telemetryStats(cmd *cobra.Command) error {
if err := r.requireTelemetry(); err != nil {
return err
}
stats, err := r.telemetry.Stats(cmd.Context())
if err != nil {
return err
}
output := cli.NewQueryOutput(cmd, r.invocation)
if output.JSON() {
return telemetryStatsJSON.Write(output, &stats)
}
state := "on"
if !stats.Enabled {
state = "off"
}
if stats.Reason != "" {
state += " (" + string(stats.Reason) + ")"
}
destination := stats.Endpoint
if destination == "" {
destination = "not configured"
}
_, err = fmt.Fprintf(
output.Human(),
"Telemetry: %s\nClient ID: %s\nStatistics since: %s\n"+
"Destination: %s\nAccepted records: %d (events: %d, reports: %d)\n"+
"Accepted event payload: %d bytes\nAccepted report content: %d bytes\n"+
"Upload request bodies: %d bytes in %d attempts (%d uncertain)\n"+
"Pending records: %d (events: %d, reports: %d; %d bytes)\n"+
"Dropped records: %d (%d bytes)\nRejected records: %d (%d bytes)\n\n"+
"Request bytes include retries but exclude HTTP and TLS overhead. "+
"Accepted content sizes do not describe current remote storage.\n",
state, stats.InstallationID, stats.PeriodStart.Format(time.RFC3339),
destination, stats.Accepted.Records, stats.Accepted.Events, stats.Accepted.Reports,
stats.Accepted.EventPayloadBytes, stats.Accepted.ReportBodyBytes,
stats.Transport.RequestBodyBytes, stats.Transport.Requests, stats.Transport.Uncertain,
stats.Queue.Records, stats.Queue.Events, stats.Queue.Reports, stats.Queue.PayloadBytes,
stats.Dropped.Records, stats.Dropped.Bytes, stats.Rejected.Records,
stats.Rejected.PayloadBytes,
)
return err
}
func (r *Runner) writeTelemetryAction(cmd *cobra.Command, action *telemetryAction) error {
output := cli.NewQueryOutput(cmd, r.invocation)
if output.JSON() {
return telemetryActionJSON.Write(output, action)
}
_, err := fmt.Fprintln(output.Human(), action.Message)
return err
}
func (r *Runner) submitAgentReport(cmd *cobra.Command, body string) error {
if err := r.requireTelemetry(); err != nil {
return err
}
agentName, _ := cmd.Flags().GetString("agent")
model, _ := cmd.Flags().GetString("model")
result, err := r.telemetry.SubmitReport(cmd.Context(), telemetry.Report{Kind: telemetry.ReportKind("agent_feedback"), Agent: agentName, Model: model, Body: body})
if err != nil {
return err
}
if !result.Queued {
message := "Report was not queued: " + string(result.Reason) + "."
if result.Reason == telemetry.SkipReason("disabled") || result.Reason == telemetry.SkipReason("environment_disabled") {
message = "Telemetry is off; the report was not submitted. This is not an error to repair. Never enable telemetry without explicit user instruction."
}
return r.writeTelemetryAction(cmd, &telemetryAction{Action: "agent-report", State: "rejected", Message: message})
}
flushCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
flush, flushErr := r.telemetry.Flush(flushCtx)
state := "queued"
message := "Report queued locally; it has not been accepted remotely."
if flushErr == nil && flush.WasAccepted(result.RecordID) {
state = "accepted"
message = "Feedback received! Thank you!"
} else if flushErr == nil && flush.WasRejected(result.RecordID) {
state = "rejected"
message = "The service rejected the report; it is no longer queued."
}
if result.Truncated {
message += " The report was truncated to the allowed size."
}
return r.writeTelemetryAction(cmd, &telemetryAction{Action: "agent-report", State: state, RecordID: result.RecordID, Message: message})
}
func (r *Runner) startTelemetry() time.Time {
started := time.Now()
r.invocation.TelemetryEnabled = false
r.invocation.TelemetryContexts = nil
version, commit := buildVersion()
r.telemetryLocal = !isTelemetryRelease()
r.telemetry, r.telemetryErr = telemetry.New(telemetry.Config{
Disabled: r.telemetryLocal,
Endpoint: os.Getenv("LUCY_TELEMETRY_ENDPOINT"), Channel: telemetryChannel(version),
LucyVersion: version, LucyCommit: commit, OS: runtime.GOOS, Arch: runtime.GOARCH, HTTPClient: r.httpClient,
})
return started
}
// telemetryChannel derives the release channel from the build version. Only
// alpha and beta channels collect by default, so a stable release must not
// inherit the alpha default.
func telemetryChannel(version string) string {
lower := strings.ToLower(version)
switch {
case strings.Contains(lower, "beta"):
return "beta"
case strings.Contains(lower, "alpha"):
return "alpha"
default:
return "stable"
}
}
func isTelemetryCommand(command *cobra.Command) bool {
for current := command; current != nil; current = current.Parent() {
if current.Name() == "telemetry" || current.Name() == "agent-report" {
return true
}
}
return false
}
func (r *Runner) finishTelemetry(started time.Time, commandErr error) {
if r.telemetry == nil {
return
}
defer r.telemetry.Close()
command := r.invocationCommand()
if isTelemetryCommand(command) || command.Name() == "__complete" || command.Name() == "__completeNoDesc" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
r.recordInvocation(ctx, command, started, commandErr)
if noOpExit(command, commandErr) {
return
}
if offline := command.Flags().Lookup("offline"); offline != nil && offline.Value.String() == "true" {
return
}
flush, flushErr := r.telemetry.Flush(ctx)
if flushErr == nil && flush.Attempted > 0 && !r.JSONSelected() && r.stderr != nil {
fmt.Fprintln(r.stderr, "Telemetry upload attempted; run `lucy telemetry stats` for local totals.")
}
}
// noOpExit reports an invocation that displayed guidance instead of running a
// command: help for the addressed command, the help command, a version
// request, or a command group without a subcommand. The event stays queued, so
// a machine that only reads help sends nothing.
func noOpExit(command *cobra.Command, commandErr error) bool {
if commandErr != nil {
return false
}
if cli.IsHelpOnly(command) || command.Name() == "help" {
return true
}
for current := command; current != nil; current = current.Parent() {
for _, name := range []string{"help", "version"} {
if flag := current.Flags().Lookup(name); flag != nil && flag.Value.String() == "true" {
return true
}
}
}
return !command.Runnable()
}
// recordInvocation records the terminal event and, for failures, a diagnostic
// report. An unidentified build skips this call so harness traffic cannot pass
// as user activity, but it still flushes records queued by identified builds.
func (r *Runner) recordInvocation(ctx context.Context, command *cobra.Command, started time.Time, commandErr error) {
outcome := telemetry.Outcome("success")
code := ""
reportKind := telemetry.ReportKind("")
if commandErr != nil {
outcome = telemetry.Outcome("failure")
code = cli.CodeCommandFailed
_, panicked := errors.AsType[*panicError](commandErr)
exit, hasExit := errors.AsType[*cli.ExitError](commandErr)
switch {
case errors.Is(commandErr, context.Canceled), errors.Is(commandErr, context.DeadlineExceeded):
outcome, code = telemetry.Outcome("canceled"), cli.CodeOperationCancelled
case cli.IsUsageError(commandErr):
code = cli.CodeInvalidInvocation
case panicked:
reportKind = telemetry.ReportKind("panic")
case hasExit && exit != nil:
if !exit.Silent {
reportKind = telemetry.ReportKind("failure")
}
default:
reportKind = telemetry.ReportKind("failure")
}
if detail, ok := errors.AsType[*report.Detail](commandErr); ok && detail != nil && detail.Code != "" {
code = detail.Code
}
}
if r.invocation.TelemetryEnabled {
r.invocation.TelemetryContexts = append(r.invocation.TelemetryContexts, telemetry.Context{
Kind: "agent", Status: telemetry.ContextPresent,
Data: struct {
Detected bool `json:"detected"`
}{Detected: agent.Detected()},
})
}
id := telemetry.NewUUID()
_, _ = r.telemetry.RecordEvent(ctx, telemetry.Event{
InvocationID: id, Command: strings.TrimPrefix(command.CommandPath(), "lucy "),
Duration: time.Since(started), Outcome: outcome, ErrorCode: code,
OccurredAt: started, Contexts: r.invocation.TelemetryContexts,
})
if reportKind != "" {
body := cli.ReportBody{
Command: command.CommandPath(),
Code: code,
Stages: cli.StageCounts(r.invocation.Logger.Stages()),
Facts: cli.FailureFacts(commandErr),
}
if commandErr != nil {
body.Message = commandErr.Error()
}
if panicFailure, ok := errors.AsType[*panicError](commandErr); ok && panicFailure != nil {
body.Stack = telemetryPanicFunctions(panicFailure.stack)
}
_, _ = r.telemetry.SubmitReport(ctx, telemetry.Report{Kind: reportKind, InvocationID: id, Body: body.Encode()})
}
}
func telemetryPanicFunctions(stack []byte) []string {
var functions []string
for _, line := range strings.Split(string(stack), "\n") {
if !strings.HasPrefix(line, "github.com/mclucy/lucy/") {
continue
}
name, _, found := strings.CutLast(line, "(")
if !found || len(name) > 160 {
continue
}
if strings.Trim(name, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._/-()*") != "" {
continue
}
functions = append(functions, name)
if len(functions) == 32 {
break
}
}
return functions
}
package cmd
import (
"runtime/debug"
"strings"
)
// buildVersion resolves the display identity from linker flags or Go build information.
func buildVersion() (string, string) {
displayVersion, displayCommit := version, commit
info, ok := debug.ReadBuildInfo()
if !ok {
return displayVersion, displayCommit
}
revision := ""
for _, setting := range info.Settings {
switch setting.Key {
case "vcs.revision":
revision = setting.Value
}
}
if displayVersion == "" && revision == "" && info.Main.Version != "" && info.Main.Version != "(devel)" {
displayVersion = strings.TrimPrefix(info.Main.Version, "v")
}
if displayCommit == "" && revision != "" {
if len(revision) > 7 {
revision = revision[:7]
}
displayCommit = revision
}
return displayVersion, displayCommit
}
func isTelemetryRelease() bool {
if telemetryRelease == "true" {
return true
}
info, ok := debug.ReadBuildInfo()
return ok && info.Main.Sum != "" && info.Main.Version != "" && info.Main.Version != "(devel)"
}
// Package config manages machine-local global and workspace configuration.
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/log"
)
const WorkspaceFilename = ".lucy/config.yaml"
type Scope uint8
const (
WorkspaceScope Scope = iota
GlobalScope
)
type Service struct {
root string
globalPath string
globalErr error
logger *log.Logger
}
type WriteResult struct {
Path string `json:"path"`
Created bool `json:"created"`
}
// New builds the service for root. An unavailable user configuration directory
// only fails requests for the global scope, so Java selection still works in
// environments without a home directory.
func New(root string, logger *log.Logger) (*Service, error) {
absolute, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolve workspace directory: %w", err)
}
service := &Service{root: absolute, logger: logger}
directory, err := os.UserConfigDir()
if err != nil {
service.globalErr = fmt.Errorf("locate global config directory: %w", err)
return service, nil
}
service.globalPath = filepath.Join(directory, "lucy", "config.yaml")
return service, nil
}
func configFile(path string) document.Service[Values] {
return document.NewService(path, document.Codec[Values]{
Decode: decodeValues,
Encode: encodeValues,
Merge: mergeConfigBytes,
}).WithMode(0o600)
}
func (s *Service) file(scope Scope) (document.Service[Values], error) {
switch scope {
case WorkspaceScope:
return configFile(filepath.Join(s.root, filepath.FromSlash(WorkspaceFilename))), nil
case GlobalScope:
if s.globalErr != nil {
return document.Service[Values]{}, s.globalErr
}
return configFile(s.globalPath), nil
default:
return document.Service[Values]{}, fmt.Errorf("unknown config scope")
}
}
func (s *Service) read(scope Scope, required bool) (*Values, error) {
file, err := s.file(scope)
if err != nil {
if scope == GlobalScope && !required {
return &Values{}, nil
}
return nil, err
}
values, err := file.Read()
if errors.Is(err, os.ErrNotExist) {
if !required {
return &Values{}, nil
}
command := "lucy config create"
if scope == GlobalScope {
command += " --global"
}
return nil, fmt.Errorf("config %s does not exist; run `%s` to create it: %w", file.Path(), command, err)
}
if err != nil {
return nil, fmt.Errorf("read config %s: %w", file.Path(), err)
}
return values, nil
}
func (s *Service) Get(key string, scope Scope) (any, error) {
selected, err := s.read(scope, true)
if err != nil {
return nil, err
}
global, workspace := selected, &Values{}
if scope == WorkspaceScope {
workspace = selected
global, err = s.read(GlobalScope, false)
if err != nil {
return nil, err
}
}
merged := mergeValues(global, workspace)
if key == "" {
return merged, nil
}
value, err := fieldValue(merged, key)
if err != nil {
return nil, err
}
if value.Kind() == reflect.Pointer {
return value.Elem().Interface(), nil
}
return value.Interface(), nil
}
// Setting returns one effective string setting. Missing layers are empty, and
// a workspace value overrides a global one.
func (s *Service) Setting(key string) (string, error) {
global, err := s.read(GlobalScope, false)
if err != nil {
return "", err
}
workspace, err := s.read(WorkspaceScope, false)
if err != nil {
return "", err
}
merged := mergeValues(global, workspace)
value, err := fieldValue(merged, key)
if err != nil {
return "", err
}
if value.Kind() != reflect.Pointer {
return "", fmt.Errorf("config key %q is not a string", key)
}
if value.IsNil() {
return "", nil
}
return value.Elem().String(), nil
}
// GitHubProxy returns the configured prefix proxy for GitHub release
// downloads. An empty prefix downloads directly. A hand-edited value that is
// not an absolute HTTP(S) URL is an error rather than a broken download.
func (s *Service) GitHubProxy() (string, error) {
value, err := s.Setting("github.proxy")
if err != nil {
return "", err
}
value = strings.TrimSpace(value)
if err := validateProxyURL(value); err != nil {
return "", err
}
return value, nil
}
// CurseForgeAPIKey returns the configured CurseForge API key. An empty key
// lets the provider use the key embedded in this build.
func (s *Service) CurseForgeAPIKey() (string, error) {
return s.Setting("curseforge.api_key")
}
// JavaHome resolves the selected path against the directory of its source
// scope. Missing config files do not disable automatic runtime selection.
func (s *Service) JavaHome() (home string, sourcePath string, err error) {
global, err := s.read(GlobalScope, false)
if err != nil {
return "", "", err
}
workspace, err := s.read(WorkspaceScope, false)
if err != nil {
return "", "", err
}
selected, base, source := global.Java.Home, filepath.Dir(s.globalPath), s.globalPath
if workspace.Java.Home != nil {
selected, base, source = workspace.Java.Home, s.root, filepath.Join(s.root, WorkspaceFilename)
}
if selected == nil || *selected == "" {
return "", "", nil
}
home = *selected
if !filepath.IsAbs(home) {
home = filepath.Join(base, home)
}
if info, statErr := os.Stat(home); statErr == nil && !info.IsDir() {
home = filepath.Dir(filepath.Dir(home))
}
return filepath.Clean(home), source, nil
}
package config
import (
"reflect"
"strings"
)
// RedactedValue replaces a secret setting in configuration output.
const RedactedValue = "********"
// Secret reports whether a setting holds a credential that output must mask.
func Secret(key string) bool {
field, ok := schemaFieldByKey(key)
return ok && field.Tag.Get("secret") == "true"
}
// Redacted returns value with every secret setting masked, so the output is
// safe to share. A caller that needs one secret reads that setting by its exact
// key instead.
func Redacted(value any) any {
switch value := value.(type) {
case *Values:
if value == nil {
return value
}
copied := *value
redactSecrets(reflect.ValueOf(&copied).Elem())
return &copied
case CurseForge:
redactSecrets(reflect.ValueOf(&value).Elem())
return value
default:
return value
}
}
func schemaFieldByKey(key string) (reflect.StructField, bool) {
if key == "" {
return reflect.StructField{}, false
}
t := reflect.TypeFor[Values]()
var field reflect.StructField
for part := range strings.SplitSeq(key, ".") {
next, ok := schemaField(t, part)
if !ok {
return reflect.StructField{}, false
}
field, t = next, next.Type
}
return field, true
}
// redactSecrets masks the non-empty secret fields reachable from value in
// place. An empty secret keeps its empty value, which selects the fallback.
func redactSecrets(value reflect.Value) {
for value.Kind() == reflect.Pointer {
if value.IsNil() {
return
}
value = value.Elem()
}
if value.Kind() != reflect.Struct {
return
}
t := value.Type()
for i := range t.NumField() {
field := t.Field(i)
if !field.IsExported() {
continue
}
child := value.Field(i)
if field.Tag.Get("secret") == "true" &&
child.Kind() == reflect.Pointer &&
child.Type().Elem().Kind() == reflect.String &&
!child.IsNil() &&
child.Elem().String() != "" {
child.Elem().SetString(RedactedValue)
continue
}
redactSecrets(child)
}
}
package config
import (
"bytes"
"errors"
"fmt"
"io"
"reflect"
"strings"
"github.com/mclucy/lucy/document"
"gopkg.in/yaml.v3"
)
// Values describes one configuration layer. A nil scalar inherits from the
// previous layer; an explicit empty value selects that setting's automatic
// behavior and masks an inherited value.
type Values struct {
Java Java `yaml:"java,omitempty" json:"java" description:"Java runtime selection."`
GitHub GitHub `yaml:"github,omitempty" json:"github" description:"GitHub access."`
CurseForge CurseForge `yaml:"curseforge,omitempty" json:"curseforge" description:"CurseForge access."`
}
type Java struct {
Home *string `yaml:"home,omitempty" json:"home" description:"Java home or executable. An empty value lets Lucy choose a runtime." example:"/path/to/java-home"`
}
type GitHub struct {
Proxy *string `yaml:"proxy,omitempty" json:"proxy" description:"Prefix proxy for GitHub release downloads. An empty value downloads directly." example:"https://ghfast.top/"`
}
type CurseForge struct {
APIKey *string `yaml:"api_key,omitempty" json:"api_key" secret:"true" description:"CurseForge API key. An empty value uses the key built into this Lucy build." example:"$2a$10$example"`
}
func decodeValues(reader io.Reader) (*Values, error) {
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
decoder := yaml.NewDecoder(bytes.NewReader(data))
var node yaml.Node
if err := decoder.Decode(&node); errors.Is(err, io.EOF) {
return &Values{}, nil
} else if err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
var tail yaml.Node
if err := decoder.Decode(&tail); !errors.Is(err, io.EOF) {
if err != nil {
return nil, fmt.Errorf("decode config tail: %w", err)
}
return nil, fmt.Errorf("config must contain one YAML document")
}
if len(node.Content) == 0 {
return &Values{}, nil
}
if err := validateFields(node.Content[0], reflect.TypeFor[Values](), ""); err != nil {
return nil, err
}
values := &Values{}
if err := node.Decode(values); err != nil {
return nil, fmt.Errorf("decode config values: %w", err)
}
return values, nil
}
func encodeValues(values *Values) ([]byte, error) {
return document.NewCommenter(document.YAMLOptions{}).Render(values)
}
func (values *Values) PlainBytes() ([]byte, error) {
return document.NewCommenter(document.YAMLOptions{NoComments: true}).Render(values)
}
// mergeConfigBytes writes values into the configuration the file already
// holds. Comments the user wrote survive, and new keys arrive with the
// documentation declared by the config types.
func mergeConfigBytes(existing []byte, values *Values) ([]byte, error) {
if len(bytes.TrimSpace(existing)) == 0 {
return encodeValues(values)
}
if _, err := decodeValues(bytes.NewReader(existing)); err != nil {
return nil, err
}
return document.NewCommenter(document.YAMLOptions{}).Merge(existing, values)
}
func schemaField(t reflect.Type, name string) (reflect.StructField, bool) {
for i := range t.NumField() {
field := t.Field(i)
if field.IsExported() && yamlName(field) == name {
return field, true
}
}
return reflect.StructField{}, false
}
func yamlName(field reflect.StructField) string {
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
return name
}
func validateFields(node *yaml.Node, t reflect.Type, prefix string) error {
if node.Kind == yaml.AliasNode {
node = node.Alias
}
if node.Tag == "!!null" {
return nil
}
if node.Kind != yaml.MappingNode {
return fmt.Errorf("config %s must be a mapping", strings.TrimSuffix(prefix, "."))
}
for i := 0; i < len(node.Content); i += 2 {
key, value := node.Content[i], node.Content[i+1]
field, ok := schemaField(t, key.Value)
if !ok {
return fmt.Errorf("unknown config key %q", prefix+key.Value)
}
if field.Type.Kind() == reflect.Struct {
if err := validateFields(value, field.Type, prefix+key.Value+"."); err != nil {
return err
}
continue
}
if value.Tag == "!!null" {
continue
}
if value.Kind == yaml.AliasNode {
value = value.Alias
}
if field.Type.Kind() != reflect.Pointer || field.Type.Elem().Kind() != reflect.String {
return fmt.Errorf("unsupported config schema field %q", prefix+key.Value)
}
if value.Kind != yaml.ScalarNode || value.Tag != "!!str" {
return fmt.Errorf("config %s must be a string", prefix+key.Value)
}
}
return nil
}
func fieldValue(values *Values, key string) (reflect.Value, error) {
value := reflect.ValueOf(values).Elem()
for part := range strings.SplitSeq(key, ".") {
if value.Kind() != reflect.Struct {
return reflect.Value{}, fmt.Errorf("unknown config key %q", key)
}
field, ok := schemaField(value.Type(), part)
if !ok {
return reflect.Value{}, fmt.Errorf("unknown config key %q", key)
}
value = value.FieldByIndex(field.Index)
}
return value, nil
}
func mergeValues(global, workspace *Values) *Values {
merged := &Values{}
mergeFields(reflect.ValueOf(merged).Elem(), reflect.ValueOf(global).Elem(), reflect.ValueOf(workspace).Elem())
return merged
}
func mergeFields(target, global, workspace reflect.Value) {
for i := range target.Type().NumField() {
field := target.Type().Field(i)
if !field.IsExported() {
continue
}
dst := target.FieldByIndex(field.Index)
left, right := global.FieldByIndex(field.Index), workspace.FieldByIndex(field.Index)
if dst.Kind() == reflect.Struct {
mergeFields(dst, left, right)
continue
}
switch {
case !right.IsNil():
dst.Set(right)
case !left.IsNil():
dst.Set(left)
default:
dst.Set(reflect.New(dst.Type().Elem()))
}
}
}
package config
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/internal/workspacewrite"
)
func (s *Service) lock(scope Scope) (*fsutil.FileLock, error) {
if scope == WorkspaceScope {
if err := workspacewrite.SafePath(s.root, WorkspaceFilename); err != nil {
return nil, err
}
writer, err := workspacewrite.Lock(s.root, s.logger)
if err != nil {
return nil, err
}
if err := workspacewrite.RecoverInstall(s.root, s.logger); err != nil {
return nil, errors.Join(err, writer.Close())
}
return writer, nil
}
if scope != GlobalScope {
return nil, fmt.Errorf("unknown config scope")
}
if s.globalErr != nil {
return nil, s.globalErr
}
directory := filepath.Dir(s.globalPath)
if err := fsutil.MkdirAllPrivate(directory); err != nil {
return nil, err
}
return fsutil.TryLockFile(filepath.Join(directory, "writer.lock"))
}
// workspaceRoot opens the workspace directory that confines configuration IO.
// Global configuration has no such root because only this user can change its
// private directory.
func (s *Service) workspaceRoot(scope Scope) (*os.Root, error) {
if scope != WorkspaceScope {
return nil, nil
}
return os.OpenRoot(s.root)
}
// absent reports whether the configuration file is free. A workspace check runs
// through its root handle, so a replaced parent cannot answer for a foreign
// file.
func absent(file document.Service[Values], root *os.Root) (bool, error) {
var err error
if root == nil {
_, err = os.Lstat(file.Path())
} else {
_, err = root.Lstat(WorkspaceFilename)
}
switch {
case err == nil:
return false, nil
case errors.Is(err, os.ErrNotExist):
return true, nil
default:
return false, err
}
}
// readValues reads through root when the scope has one, so a replaced parent
// cannot feed a write with a foreign document.
func readValues(file document.Service[Values], root *os.Root) (*Values, error) {
if root == nil {
return file.Read()
}
return file.ReadRoot(root, WorkspaceFilename)
}
// writeValues publishes through root when the scope has one, keeping reading,
// staging, and renaming on the handle the writer lock verified.
func writeValues(file document.Service[Values], root *os.Root, values *Values, replace bool) error {
if root == nil {
if replace {
return file.Write(values)
}
return file.Create(values)
}
if replace {
return file.WriteRoot(root, WorkspaceFilename, values)
}
return file.CreateRoot(root, WorkspaceFilename, values)
}
// CreateWorkspace publishes a fresh workspace configuration when the file is
// absent and reports whether it wrote one. The write stays confined to the
// workspace root.
func CreateWorkspace(root string, plain bool) (created bool, resultErr error) {
files, err := os.OpenRoot(root)
if err != nil {
return false, err
}
defer func() { resultErr = errors.Join(resultErr, files.Close()) }()
file := configFile(filepath.Join(root, filepath.FromSlash(WorkspaceFilename)))
if plain {
file = file.WithEncoder((*Values).PlainBytes)
}
err = file.CreateRoot(files, WorkspaceFilename, &Values{})
if errors.Is(err, os.ErrExist) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
type CreateOptions struct {
Force bool
NoComments bool
}
func (s *Service) Create(scope Scope, options CreateOptions) (result *WriteResult, resultErr error) {
writer, err := s.lock(scope)
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
file, err := s.file(scope)
if err != nil {
return nil, err
}
if options.NoComments {
file = file.WithEncoder((*Values).PlainBytes)
}
// Create replaces the file by definition: a forced create must not merge
// its empty template into the values it is discarding.
file = file.WithoutMerge()
root, err := s.workspaceRoot(scope)
if err != nil {
return nil, err
}
if root != nil {
defer func() { resultErr = errors.Join(resultErr, root.Close()) }()
}
created, err := absent(file, root)
if err != nil {
return nil, err
}
if !created && !options.Force {
return nil, fmt.Errorf("config %s already exists; use --force to replace it with a fresh config", file.Path())
}
if err := writeValues(file, root, &Values{}, options.Force); err != nil {
return nil, err
}
return &WriteResult{Path: file.Path(), Created: created}, nil
}
// validateSetting rejects values that the field's meaning cannot accept.
func validateSetting(key, text string) error {
switch key {
case "github.proxy":
return validateProxyURL(text)
default:
return nil
}
}
func validateProxyURL(text string) error {
if text == "" {
return nil
}
parsed, err := url.Parse(text)
if err != nil ||
(parsed.Scheme != "http" && parsed.Scheme != "https") ||
parsed.Host == "" ||
parsed.RawQuery != "" ||
parsed.Fragment != "" {
return fmt.Errorf("config github.proxy must be an absolute HTTP(S) URL without a query or fragment")
}
return nil
}
func (s *Service) Set(key, text string, scope Scope) (result *WriteResult, resultErr error) {
probe, err := fieldValue(&Values{}, key)
if err != nil {
return nil, err
}
if probe.Kind() != reflect.Pointer || probe.Type().Elem().Kind() != reflect.String {
return nil, fmt.Errorf("config key %q requires a field, not a section", key)
}
if strings.ContainsRune(text, '\x00') {
return nil, fmt.Errorf("config %s cannot contain a null character", key)
}
if err := validateSetting(key, text); err != nil {
return nil, err
}
writer, err := s.lock(scope)
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
file, err := s.file(scope)
if err != nil {
return nil, err
}
root, err := s.workspaceRoot(scope)
if err != nil {
return nil, err
}
if root != nil {
defer func() { resultErr = errors.Join(resultErr, root.Close()) }()
}
values, err := readValues(file, root)
created := errors.Is(err, os.ErrNotExist)
if created {
values = &Values{}
} else if err != nil {
return nil, fmt.Errorf("read config %s: %w", file.Path(), err)
}
field, err := fieldValue(values, key)
if err != nil {
return nil, err
}
field.Set(reflect.ValueOf(new(text)))
if err := writeValues(file, root, values, !created); err != nil {
return nil, err
}
return &WriteResult{Path: file.Path(), Created: created}, nil
}
//go:build !docsarchive
package docs
import (
"embed"
"io/fs"
)
// Sources are the tracked page files, read directly by builds that do not pack
// the documentation archive.
//
//go:embed *.mdx agents/*.mdx commands/*.mdx file-tracking/*.mdx
var sources embed.FS
// Load returns the embedded pages sorted by slug.
func Load() ([]Page, error) {
var pages []Page
err := fs.WalkDir(sources, ".", func(name string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
slug, ok := pageName(name)
if !ok {
return nil
}
content, err := sources.ReadFile(name)
if err != nil {
return err
}
pages = append(pages, Page{Slug: slug, Content: content})
return nil
})
if err != nil {
return nil, err
}
sortPages(pages)
return pages, nil
}
// Package docs supplies the documentation pages embedded in lucy.
package docs
import (
"cmp"
"slices"
"strings"
)
// Page is one documentation page. Slug is the page path without the .mdx suffix.
type Page struct {
Slug string
Content []byte
}
// pageName reports the slug of a page path. Drafts are not pages.
func pageName(name string) (string, bool) {
if !strings.HasSuffix(name, ".mdx") || strings.HasSuffix(name, ".draft.mdx") {
return "", false
}
return strings.TrimSuffix(name, ".mdx"), true
}
func sortPages(pages []Page) {
slices.SortFunc(pages, func(a, b Page) int {
return cmp.Compare(a.Slug, b.Slug)
})
}
package document
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json/v2"
"fmt"
"io"
"slices"
"github.com/mclucy/lucy/catalog"
javapkg "github.com/mclucy/lucy/java"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/version"
"gopkg.in/yaml.v3"
)
const (
LockFilename = "lucy-lock.yaml"
LockSchemaVersion = 1
)
type Lock struct {
SchemaVersion int `yaml:"lock_version" json:"lock_version"`
ManifestFingerprint string `yaml:"manifest_fingerprint" json:"manifest_fingerprint"`
Server LockedServer `yaml:"server" json:"server"`
MCDR *LockedMCDR `yaml:"mcdr,omitempty" json:"mcdr,omitempty"`
Packages []Package `yaml:"packages" json:"packages"`
Files []TrackedFile `yaml:"files,omitempty" json:"files,omitempty"`
}
type LockedServer struct {
Directory string `yaml:"directory,omitempty" json:"directory"`
Minecraft version.Version `yaml:"minecraft" json:"minecraft"`
Product server.Product `yaml:"product" json:"product"`
CoreVersion version.Version `yaml:"core_version" json:"core_version"`
catalog.Artifact `yaml:",inline" json:",inline"`
Bootstrapper string `yaml:"bootstrapper" json:"bootstrapper"`
Inputs []server.BootstrapInput `yaml:"inputs,omitempty" json:"inputs"`
Java LockedJava `yaml:"java" json:"java"`
}
// LockedJava records the inputs of the Java requirement. Package requirements
// on Java stay in the package modules, so commands combine the inputs when
// they check a Java runtime.
type LockedJava struct {
// Advisory is the Java major version that Mojang recommends for the
// Minecraft version. Lucy treats it as a minimum.
Advisory int `yaml:"advisory" json:"advisory"`
// Version is the major version that the manifest pins; zero follows the
// advisory.
Version int `yaml:"version,omitempty" json:"version,omitempty"`
// Distribution is the distribution that the manifest pins; empty accepts
// any build.
Distribution javapkg.Distribution `yaml:"distribution,omitempty" json:"distribution,omitempty"`
}
// NewLockedJava records the Mojang advisory with the override that the
// manifest declares.
func NewLockedJava(advisory int, override *ManifestJava) LockedJava {
java := LockedJava{Advisory: advisory}
if override != nil {
java.Version = override.Version
java.Distribution = override.Distribution
}
return java
}
type LockedMCDR struct {
Directory string `yaml:"directory" json:"directory"`
Version version.Version `yaml:"version" json:"version"`
}
func (l *Lock) Validate() error {
if l.SchemaVersion != LockSchemaVersion {
return fmt.Errorf("unsupported lock version %d", l.SchemaVersion)
}
if l.ManifestFingerprint == "" {
return fmt.Errorf("manifest_fingerprint is required")
}
if l.Server.Directory == "" {
return fmt.Errorf("server.directory is required")
}
if err := SafeRelativePath(l.Server.Directory); err != nil {
return err
}
if err := validateVersion("server.minecraft", l.Server.Minecraft, false); err != nil {
return err
}
if l.Server.Product == server.ProductUnknown {
return fmt.Errorf("server.product is required")
}
if err := validateVersion("server.core_version", l.Server.CoreVersion, true); err != nil {
return err
}
if l.Server.Artifact.Version == "" {
return fmt.Errorf("server.artifact.version is required")
}
if l.Server.Artifact.Filename == "" {
return fmt.Errorf("server.artifact.filename is required")
}
if l.Server.Java.Advisory <= 0 {
return fmt.Errorf("server.java.advisory must be positive")
}
distribution, ok := javapkg.ParseDistribution(string(l.Server.Java.Distribution))
if !ok {
return fmt.Errorf("unknown server.java.distribution %q", l.Server.Java.Distribution)
}
l.Server.Java.Distribution = distribution
if l.MCDR != nil {
if l.MCDR.Directory == "" {
return fmt.Errorf("mcdr.directory is required")
}
if err := SafeRelativePath(l.MCDR.Directory); err != nil {
return err
}
if err := validateVersion("mcdr.version", l.MCDR.Version, false); err != nil {
return err
}
}
for i := range l.Packages {
if err := validateLockedPackage(&l.Packages[i]); err != nil {
return err
}
}
if err := NormalizeTrackedFiles(l.Files); err != nil {
return err
}
return nil
}
func validateVersion(field string, v version.Version, allowSelector bool) error {
if v == "" {
return fmt.Errorf("%s is required", field)
}
if !allowSelector && (v.CanInfer() || v.IsInvalid()) {
return fmt.Errorf("%s must be a concrete version", field)
}
return nil
}
func validateLockedPackage(pkg *Package) error {
switch pkg.Environment {
case server.EnvironmentServer, server.EnvironmentMCDR:
default:
return fmt.Errorf("package %s has unknown environment %q", pkg.Artifact.ProjectID, pkg.Environment)
}
if !pkg.Ecosystem.Valid() || pkg.Ecosystem == catalog.EcosystemUnspecified {
return fmt.Errorf("package %s has unknown ecosystem %q", pkg.Artifact.ProjectID, pkg.Ecosystem)
}
if pkg.Artifact.Provider == catalog.ProviderUnspecified || pkg.Artifact.Provider == catalog.ProviderUnknown {
return fmt.Errorf("package %s has no provider", pkg.Artifact.ProjectID)
}
if pkg.Artifact.Version == "" || pkg.Artifact.Version.CanInfer() || pkg.Artifact.Version.IsInvalid() {
return fmt.Errorf("package %s has no concrete version", pkg.Artifact.ProjectID)
}
if pkg.Artifact.Filename == "" {
return fmt.Errorf("package %s has no filename", pkg.Artifact.ProjectID)
}
if pkg.Artifact.Hashes["sha256"] == "" {
return fmt.Errorf("package %s has no sha256 hash", pkg.Artifact.ProjectID)
}
for j := range pkg.Modules {
module := &pkg.Modules[j]
if !module.VersionDialect.Valid() {
return fmt.Errorf(
"package %s module %s requires a known version dialect, got %q",
pkg.Artifact.ProjectID,
module.ID,
module.VersionDialect,
)
}
}
for j := range pkg.Requirements {
requirement := &pkg.Requirements[j]
if requirement.Reference == "" {
return fmt.Errorf("package %s has an empty requirement reference", pkg.Artifact.ProjectID)
}
if requirement.Selector == "" {
return fmt.Errorf("package %s has an empty requirement selector", pkg.Artifact.ProjectID)
}
}
return nil
}
// DecodeLock validates lock bytes from a reader.
func DecodeLock(reader io.Reader) (*Lock, error) {
l := &Lock{}
decoder := yaml.NewDecoder(reader)
decoder.KnownFields(true)
if err := decoder.Decode(l); err != nil {
return nil, fmt.Errorf("decode lock: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return nil, fmt.Errorf("lock must contain one YAML document")
}
return nil, fmt.Errorf("decode lock tail: %w", err)
}
if err := l.Validate(); err != nil {
return nil, err
}
return l, nil
}
// Fingerprint returns the identity of a lock's content. It validates a copy
// first, so the fingerprint describes what the lock says and not how it was
// spelled.
func (l *Lock) Fingerprint() (string, error) {
canonical, err := l.canonical()
if err != nil {
return "", err
}
data, err := json.Marshal(canonical, json.Deterministic(true))
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
func (l *Lock) canonical() (*Lock, error) {
if l == nil {
return nil, nil
}
clone := *l
clone.Files = slices.Clone(l.Files)
if err := clone.Validate(); err != nil {
return nil, err
}
return &clone, nil
}
// Snapshot returns the canonical bytes of the document. It writes nothing. A
// publisher that decides when and where the bytes land takes a snapshot first;
// Service.Write takes one and publishes it.
func (l *Lock) Snapshot() ([]byte, error) {
if err := l.Validate(); err != nil {
return nil, err
}
return NewCommenter(YAMLOptions{NoComments: true}).Render(l)
}
// MergeBytes writes l into existing and keeps the comments the existing lock
// carries. The lock declares no generated comments of its own.
func (l *Lock) MergeBytes(existing []byte) ([]byte, error) {
if len(bytes.TrimSpace(existing)) == 0 {
return l.Snapshot()
}
if _, err := DecodeLock(bytes.NewReader(existing)); err != nil {
return nil, err
}
if err := l.Validate(); err != nil {
return nil, err
}
return NewCommenter(YAMLOptions{NoComments: true}).Merge(existing, l)
}
// LookupReference expands a bare name against the package names in this lock.
// It returns an empty string when no package matches and an error when the
// name is ambiguous.
func (l *Lock) LookupReference(input string) (string, error) {
var match string
for _, pkg := range l.Packages {
if pkg.Artifact.ProjectID != input {
continue
}
ref := pkg.Artifact.Provider.String() + ":" + pkg.Artifact.ProjectID
if match != "" && match != ref {
return "", fmt.Errorf("ambiguous package name %q", input)
}
match = ref
}
return match, nil
}
package document
import (
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/version"
)
// RequirementBinding retains a declared reference's immutable project identity,
// including when the requirement is disabled.
type RequirementBinding struct {
Reference string `yaml:"reference" json:"reference"`
Selector version.Version `yaml:"selector" json:"selector"`
FilePattern string `yaml:"file_pattern,omitempty" json:"file_pattern,omitempty"`
Enabled bool `yaml:"enabled" json:"enabled"`
}
// Package records an artifact pin and its requirement bindings, not installed state.
type Package struct {
Environment server.Environment `yaml:"environment" json:"environment"`
Ecosystem catalog.Ecosystem `yaml:"ecosystem" json:"ecosystem"`
catalog.Artifact `yaml:",inline"`
Requirements []RequirementBinding `yaml:"requirements,omitempty" json:"requirements,omitempty"`
// Dependency records selection through another package's dependency.
Dependency bool `yaml:"dependency,omitempty" json:"dependency,omitzero"`
// DependencyOf keeps the required dependents that justify this pin.
DependencyOf []catalog.Ref `yaml:"dependency_of,omitempty" json:"dependency_of,omitempty"`
}
// Enabled reports whether p is selected for installation. Dependency demand
// overrides disabled direct bindings; an unbound package without dependents is ambient.
func (p *Package) Enabled() bool {
if p.Dependency || (len(p.Requirements) == 0 && len(p.DependencyOf) == 0) {
return true
}
for _, r := range p.Requirements {
if r.Enabled {
return true
}
}
return false
}
package document
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json/v2"
"fmt"
"io"
"path/filepath"
"strings"
"unicode"
"unicode/utf8"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/version"
"gopkg.in/yaml.v3"
)
const (
ManifestFilename = "lucy.yaml"
ManifestSchemaVersion = 1
)
type Manifest struct {
SchemaVersion int `yaml:"manifest_version" json:"manifest_version" description:"Manifest schema version" example:"1"`
Name string `yaml:"name,omitempty" json:"name,omitempty" description:"Server name used to select and register the workspace" example:"survival"`
Server ManifestServer `yaml:"server" json:"server" description:"Declared Minecraft server"`
MCDR *ManifestMCDR `yaml:"mcdr,omitempty" json:"mcdr,omitempty" description:"Optional MCDReforged installation" example:"-"`
Files []TrackedFile `yaml:"files,omitempty" json:"files,omitempty" description:"Tracked files with an accepted digest and change policy" example:"-"`
}
type ManifestCore struct {
Product server.Product `yaml:"product" json:"product" description:"Server implementation product" example:"paper"`
ProductVersion version.Version `yaml:"product_version,omitempty" json:"product_version" description:"Server product version selector" example:"any"`
Repository string `yaml:"repository,omitempty" json:"repository,omitempty" description:"GitHub owner/repository for a product without built-in resolution" example:"owner/repository"`
FilePattern string `yaml:"file_pattern,omitempty" json:"file_pattern,omitempty" description:"Glob pattern matching the server artifact filename" example:"'*.jar'"`
}
type ManifestServer struct {
Minecraft version.Version `yaml:"minecraft" json:"minecraft" description:"Minecraft version the server runs" example:"1.21.4"`
Directory string `yaml:"directory,omitempty" json:"directory" description:"Workspace-relative server directory" example:"."`
Core ManifestCore `yaml:"core" json:"core" description:"Server implementation declaration"`
Java *ManifestJava `yaml:"java,omitempty" json:"java,omitempty" description:"Java version and distribution requirements"`
Packages map[catalog.Ecosystem]map[string]Requirement `yaml:"packages" json:"packages" description:"Package requirements grouped by ecosystem" example:"{}"`
}
type ManifestMCDR struct {
Directory string `yaml:"directory,omitempty" json:"directory" description:"Workspace-relative MCDReforged directory" example:"."`
Version version.Version `yaml:"version,omitempty" json:"version" description:"MCDReforged version selector" example:"latest"`
Packages map[string]Requirement `yaml:"packages" json:"packages" description:"MCDReforged plugin requirements by provider:project reference" example:"{}"`
}
type Requirement struct {
Selector version.Version `yaml:"selector,omitempty" json:"selector" description:"Version selector such as any, stable, beta, a tag, or an exact version" example:"stable"`
FilePattern string `yaml:"file_pattern,omitempty" json:"file_pattern,omitempty" description:"Glob pattern matching the upstream artifact filename" example:"'*.jar'"`
Enabled *bool `yaml:"enabled,omitempty" json:"enabled" description:"Whether the requirement is active in the resolved environment" example:"true"`
}
func (r Requirement) IsEnabled() bool { return r.Enabled == nil || *r.Enabled }
func (r *Requirement) UnmarshalYAML(n *yaml.Node) error {
if n.Kind == yaml.ScalarNode {
if n.Tag != "!!str" {
return fmt.Errorf("package selector must be a quoted string")
}
r.Selector = version.Version(n.Value)
return nil
}
if n.Kind != yaml.MappingNode {
return fmt.Errorf("package requirement must be a string or object")
}
for i := 0; i < len(n.Content); i += 2 {
switch n.Content[i].Value {
case "selector", "file_pattern", "enabled":
default:
return fmt.Errorf("unknown package requirement field %q", n.Content[i].Value)
}
}
type plain Requirement
return n.Decode((*plain)(r))
}
func (r Requirement) MarshalYAML() (any, error) {
if r.FilePattern == "" && r.IsEnabled() {
return r.Selector.String(), nil
}
type plain Requirement
return plain(r), nil
}
// ParseReference splits a versionless provider:project reference.
func ParseReference(ref string) (provider catalog.Provider, project string, err error) {
providerKey, project, ok := strings.Cut(ref, ":")
if !ok || project == "" || strings.Contains(ref, "@") {
return catalog.ProviderUnknown, "", fmt.Errorf("require provider:project reference, got %q", ref)
}
provider, ok = catalog.ParseProvider(providerKey)
if !ok || !isPackageProvider(provider) {
return catalog.ProviderUnknown, "", fmt.Errorf("unsupported package provider %q", providerKey)
}
return provider, project, nil
}
func isPackageProvider(provider catalog.Provider) bool {
switch provider {
case catalog.ProviderModrinth, catalog.ProviderCurseForge, catalog.ProviderMCDR:
return true
default:
return false
}
}
// NormalizeName trims and validates the user-facing name of a workspace.
// Names are visible selectors: printable text with normal interior spaces,
// without terminal control or format characters. The registry keys entries by
// canonical path, not by name.
func NormalizeName(value string) (string, error) {
name := strings.TrimSpace(value)
if name == "" {
return "", fmt.Errorf("server name must not be empty")
}
if !utf8.ValidString(name) {
return "", fmt.Errorf("server name must be valid UTF-8")
}
for _, character := range name {
if !unicode.IsPrint(character) {
return "", fmt.Errorf("server name must contain only printable characters")
}
}
return name, nil
}
// SafeRelativePath reports whether path is a workspace-relative path that
// stays inside the workspace.
func SafeRelativePath(path string) error {
if path == "" || filepath.IsAbs(path) {
return fmt.Errorf("path must be workspace-relative: %q", path)
}
clean := filepath.Clean(path)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return fmt.Errorf("path escapes workspace: %q", path)
}
return nil
}
func (m *Manifest) Normalize() error {
if m.SchemaVersion != ManifestSchemaVersion {
return fmt.Errorf("unsupported manifest version %d", m.SchemaVersion)
}
if m.Name != "" {
name, err := NormalizeName(m.Name)
if err != nil {
return err
}
m.Name = name
}
m.Server.Minecraft = version.Version(strings.TrimSpace(m.Server.Minecraft.String()))
if declared := m.Server.Minecraft; declared == "" || declared.CanInfer() || declared.IsInvalid() {
return fmt.Errorf("server.minecraft must specify a concrete Minecraft version")
}
m.Server.Core.Product = server.Product(strings.ToLower(strings.TrimSpace(m.Server.Core.Product.String())))
if m.Server.Core.Product == server.ProductUnknown {
return fmt.Errorf("server core product is required")
}
if product, known := server.ParseProduct(m.Server.Core.Product.String()); known {
m.Server.Core.Product = product
} else if m.Server.Core.Repository == "" {
return fmt.Errorf("unsupported server product %q; GitHub forks require core.repository", m.Server.Core.Product)
}
if m.Server.Core.ProductVersion == "" {
m.Server.Core.ProductVersion = version.Any
}
if m.Server.Directory == "" {
m.Server.Directory = "."
}
if err := SafeRelativePath(m.Server.Directory); err != nil {
return err
}
m.Server.Directory = filepath.ToSlash(filepath.Clean(m.Server.Directory))
if err := normalizeJava(&m.Server); err != nil {
return err
}
if m.Server.Packages == nil {
m.Server.Packages = map[catalog.Ecosystem]map[string]Requirement{}
}
for ecosystem, packages := range m.Server.Packages {
switch ecosystem {
case catalog.EcosystemFabric,
catalog.EcosystemForge,
catalog.EcosystemNeoForge,
catalog.EcosystemBukkit,
catalog.EcosystemPaper:
default:
return fmt.Errorf("unsupported package ecosystem %q", ecosystem)
}
for ref, requirement := range packages {
if err := normalizeRequirement(ref, &requirement); err != nil {
return err
}
packages[ref] = requirement
}
if len(packages) == 0 {
delete(m.Server.Packages, ecosystem)
}
}
if m.MCDR != nil {
if m.MCDR.Directory == "" {
m.MCDR.Directory = "."
}
if err := SafeRelativePath(m.MCDR.Directory); err != nil {
return err
}
m.MCDR.Directory = filepath.ToSlash(filepath.Clean(m.MCDR.Directory))
if m.MCDR.Version == "" {
m.MCDR.Version = version.Any
}
if m.MCDR.Packages == nil {
m.MCDR.Packages = map[string]Requirement{}
}
for ref, requirement := range m.MCDR.Packages {
if err := normalizeRequirement(ref, &requirement); err != nil {
return err
}
m.MCDR.Packages[ref] = requirement
}
}
if err := NormalizeTrackedFiles(m.Files); err != nil {
return err
}
return nil
}
func normalizeRequirement(ref string, requirement *Requirement) error {
if _, _, err := ParseReference(ref); err != nil {
return err
}
if requirement.Selector == "" {
requirement.Selector = version.Any
}
if requirement.Enabled == nil {
enabled := true
requirement.Enabled = &enabled
}
return nil
}
// DecodeManifest validates manifest bytes from a reader.
func DecodeManifest(reader io.Reader) (*Manifest, error) {
m := &Manifest{}
decoder := yaml.NewDecoder(reader)
decoder.KnownFields(true)
if err := decoder.Decode(m); err != nil {
return nil, fmt.Errorf("decode manifest: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return nil, fmt.Errorf("manifest must contain one YAML document")
}
return nil, fmt.Errorf("decode manifest tail: %w", err)
}
if err := m.Normalize(); err != nil {
return nil, err
}
return m, nil
}
func (m *Manifest) Fingerprint() (string, error) {
if err := m.Normalize(); err != nil {
return "", err
}
// Name is user-facing metadata: renaming a workspace must not force a
// dependency resolution. Copy shallowly so normalization still applies.
canonical := *m
canonical.Name = ""
data, err := json.Marshal(&canonical, json.Deterministic(true))
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// ManifestSchemaURL is the hosted manifest schema. Lucy writes it into the
// schema header of every manifest it generates.
const ManifestSchemaURL = "https://lucy.lc/manifest.schema.json"
// CanonicalBytes includes field documentation and the YAML schema header.
func (m *Manifest) CanonicalBytes() ([]byte, error) {
return m.yamlBytes(YAMLOptions{SchemaURL: ManifestSchemaURL})
}
func (m *Manifest) PlainBytes() ([]byte, error) {
return m.yamlBytes(YAMLOptions{NoComments: true})
}
func (m *Manifest) yamlBytes(options YAMLOptions) ([]byte, error) {
if err := m.Normalize(); err != nil {
return nil, err
}
return NewCommenter(options).Render(m)
}
// MergeBytes writes m into existing. Comments the existing manifest carries
// survive, so a rewrite never replaces what a user wrote.
func (m *Manifest) MergeBytes(existing []byte) ([]byte, error) {
if len(bytes.TrimSpace(existing)) == 0 {
return m.CanonicalBytes()
}
if _, err := DecodeManifest(bytes.NewReader(existing)); err != nil {
return nil, err
}
if err := m.Normalize(); err != nil {
return nil, err
}
return NewCommenter(YAMLOptions{SchemaURL: ManifestSchemaURL}).Merge(existing, m)
}
package document
import (
"fmt"
"strconv"
"strings"
"github.com/mclucy/lucy/java"
"gopkg.in/yaml.v3"
)
// JavaAuto is the value that keeps a Java field on auto. Lucy never writes it:
// an absent field means the same.
const JavaAuto = "auto"
// MinimumJava is the oldest Java major version that any Minecraft server
// release runs on.
const MinimumJava = 8
// ManifestJava overrides the Java runtime that the server runs on. A nil
// ManifestJava or a zero field keeps that part on auto: the version follows
// the Mojang advisory for the Minecraft version, and every distribution is
// accepted.
type ManifestJava struct {
Version int `yaml:"version,omitempty" json:"version,omitempty" description:"Java major version or auto to follow the Mojang advisory" example:"auto"`
Distribution java.Distribution `yaml:"distribution,omitempty" json:"distribution,omitempty" description:"Java distribution or auto to accept any distribution" example:"auto"`
}
// IsAuto reports whether j leaves both the version and the distribution on
// auto.
func (j *ManifestJava) IsAuto() bool {
return j == nil || *j == ManifestJava{}
}
// UnmarshalYAML accepts a major version or "auto" as a scalar, or a mapping
// with version and distribution keys.
func (j *ManifestJava) UnmarshalYAML(n *yaml.Node) error {
*j = ManifestJava{}
switch n.Kind {
case yaml.ScalarNode:
version, err := parseJavaVersion(n.Value)
if err != nil {
return fmt.Errorf("server.java: %w", err)
}
j.Version = version
return nil
case yaml.MappingNode:
for i := 0; i < len(n.Content); i += 2 {
key, value := n.Content[i].Value, n.Content[i+1]
if value.Kind != yaml.ScalarNode {
return fmt.Errorf("server.java.%s must be a scalar", key)
}
switch key {
case "version":
version, err := parseJavaVersion(value.Value)
if err != nil {
return fmt.Errorf("server.java.version: %w", err)
}
j.Version = version
case "distribution":
distribution, err := ParseJavaDistribution(value.Value)
if err != nil {
return fmt.Errorf("server.java.distribution: %w", err)
}
j.Distribution = distribution
default:
return fmt.Errorf("unknown server.java field %q", key)
}
}
return nil
default:
return fmt.Errorf("server.java must be a Java major version, %s, or an object", JavaAuto)
}
}
// MarshalYAML writes the short form when only the version is set.
func (j ManifestJava) MarshalYAML() (any, error) {
if j.Distribution == java.DistributionAny {
return j.Version, nil
}
type plain ManifestJava
return plain(j), nil
}
// ParseJavaDistribution reads a distribution key, where "auto" and "any" mean
// every distribution.
func ParseJavaDistribution(s string) (java.Distribution, error) {
if strings.EqualFold(strings.TrimSpace(s), JavaAuto) {
return java.DistributionAny, nil
}
distribution, ok := java.ParseDistribution(s)
if !ok {
names := make([]string, len(java.Distributions))
for i, known := range java.Distributions {
names[i] = string(known)
}
return java.DistributionAny, fmt.Errorf(
"unknown Java distribution %q; use one of %s",
s,
strings.Join(names, ", "),
)
}
return distribution, nil
}
func parseJavaVersion(s string) (int, error) {
s = strings.TrimSpace(s)
if strings.EqualFold(s, JavaAuto) {
return 0, nil
}
version, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("%q is not a Java major version or %s", s, JavaAuto)
}
if version < MinimumJava {
return 0, fmt.Errorf("major version must be %d or later, got %d", MinimumJava, version)
}
return version, nil
}
// normalizeJava drops an override that leaves everything on auto, so the
// manifest and its fingerprint stay the same as without the field.
func normalizeJava(s *ManifestServer) error {
if s.Java.IsAuto() {
s.Java = nil
return nil
}
if s.Java.Version != 0 && s.Java.Version < MinimumJava {
return fmt.Errorf("server.java.version must be %d or later", MinimumJava)
}
distribution, ok := java.ParseDistribution(string(s.Java.Distribution))
if !ok {
return fmt.Errorf("unknown Java distribution %q", s.Java.Distribution)
}
s.Java.Distribution = distribution
return nil
}
package document
import (
"fmt"
"net/url"
"sort"
"strings"
"github.com/mclucy/lucy/input"
)
// TrackedFile declares accepted contents and a change policy, not an observed file state.
type TrackedFile struct {
Path string `yaml:"path" json:"path" description:"Workspace-relative forward-slash path to the tracked file" example:"server.jar"`
Immutable bool `yaml:"immutable,omitempty" json:"immutable,omitzero" description:"Whether deviation from the accepted digest produces a warning" example:"true"`
Hash string `yaml:"hash,omitempty" json:"hash,omitempty" description:"Accepted SHA-256 digest in sha256:hex form" example:"sha256:64 lowercase hexadecimal characters"`
RecoverURL string `yaml:"recover_url,omitempty" json:"recover_url,omitempty" description:"HTTP(S) URL used to restore missing file contents" example:"https://example.com/server.jar"`
}
// NormalizeTrackedFiles makes declaration order irrelevant to manifest fingerprints.
func NormalizeTrackedFiles(files []TrackedFile) error {
seen := make(map[string]struct{}, len(files))
for i := range files {
file := &files[i]
normalized, err := input.TrackedPath(file.Path)
if err != nil {
return err
}
file.Path = normalized
key := strings.ToLower(normalized)
if _, exists := seen[key]; exists {
return fmt.Errorf("duplicate tracked path %q", normalized)
}
seen[key] = struct{}{}
if file.Immutable && file.Hash == "" {
return fmt.Errorf("immutable file %s requires an accepted hash", file.Path)
}
if file.Hash != "" {
if len(file.Hash) != 71 || !strings.HasPrefix(file.Hash, "sha256:") {
return fmt.Errorf("tracked file %s requires sha256:<64 lowercase hexadecimal characters>", file.Path)
}
for _, digit := range file.Hash[7:] {
if !((digit >= '0' && digit <= '9') || (digit >= 'a' && digit <= 'f')) {
return fmt.Errorf("invalid accepted hash for %s", file.Path)
}
}
}
if file.RecoverURL != "" {
if file.Hash == "" {
return fmt.Errorf("recovery URL for %s requires an accepted hash", file.Path)
}
u, err := url.Parse(file.RecoverURL)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("invalid recovery URL for %s", file.Path)
}
}
}
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
return nil
}
package document
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/mclucy/lucy/internal/fsutil"
)
// Codec decodes and encodes one persisted document type.
type Codec[T any] struct {
Decode func(io.Reader) (*T, error)
Encode func(*T) ([]byte, error)
// Merge writes value into an existing encoded document. It preserves the
// comments the existing document carries, so a write never replaces user
// comments. A nil Merge replaces the document with Encode.
Merge func(existing []byte, value *T) ([]byte, error)
}
// Service reads and writes one document path. It does not lock; callers that
// need a transaction own the workspace lock.
type Service[T any] struct {
path string
codec Codec[T]
mode os.FileMode
}
// NewService binds a codec to a path. New files use 0644 unless WithMode says
// otherwise.
func NewService[T any](path string, codec Codec[T]) Service[T] {
return Service[T]{path: path, codec: codec, mode: 0o644}
}
// WithMode returns a copy of the service that creates new files with mode.
func (s Service[T]) WithMode(mode os.FileMode) Service[T] {
s.mode = mode
return s
}
// WithEncoder replaces the encoder. The encoder also replaces any existing
// document, because a caller that chooses its own bytes, such as a plain
// comment-free write, does not ask to preserve the previous document.
func (s Service[T]) WithEncoder(encode func(*T) ([]byte, error)) Service[T] {
s.codec.Encode = encode
s.codec.Merge = nil
return s
}
// WithoutMerge returns a copy that always writes a fresh document.
func (s Service[T]) WithoutMerge() Service[T] {
s.codec.Merge = nil
return s
}
func (s Service[T]) Path() string { return s.path }
func (s Service[T]) Read() (*T, error) {
file, err := os.Open(s.path)
if err != nil {
return nil, err
}
value, decodeErr := s.codec.Decode(file)
return value, errors.Join(decodeErr, file.Close())
}
func (s Service[T]) Snapshot(value *T) ([]byte, error) {
return s.codec.Encode(value)
}
func (s Service[T]) Write(value *T) error {
data, err := s.writeBytes(value)
if err != nil {
return err
}
if err := fsutil.AtomicWrite(s.path, data, s.mode); err != nil {
return err
}
return nil
}
// writeBytes merges value into the document the path already holds. A missing
// document is encoded fresh.
func (s Service[T]) writeBytes(value *T) ([]byte, error) {
if s.codec.Merge == nil {
return s.Snapshot(value)
}
existing, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return s.Snapshot(value)
}
if err != nil {
return nil, err
}
return s.codec.Merge(existing, value)
}
// writeRootBytes merges value into the document relative names inside root. A
// missing document is encoded fresh.
func (s Service[T]) writeRootBytes(root *os.Root, relative string, value *T) ([]byte, error) {
if s.codec.Merge == nil {
return s.Snapshot(value)
}
file, err := root.Open(relative)
if errors.Is(err, os.ErrNotExist) {
return s.Snapshot(value)
}
if err != nil {
return nil, err
}
existing, readErr := io.ReadAll(file)
closeErr := file.Close()
if err := errors.Join(readErr, closeErr); err != nil {
return nil, err
}
return s.codec.Merge(existing, value)
}
// Create publishes value only while the destination is still absent. A file
// that appears after the caller's check survives.
func (s Service[T]) Create(value *T) error {
data, err := s.Snapshot(value)
if err != nil {
return err
}
parent := filepath.Dir(s.path)
if err := fsutil.MkdirAllPrivate(parent); err != nil {
return fmt.Errorf("create file parent: %w", err)
}
root, err := os.OpenRoot(parent)
if err != nil {
return fmt.Errorf("open file parent: %w", err)
}
writeErr := fsutil.WriteRoot(
root,
filepath.Base(s.path),
bytes.NewReader(data),
fsutil.WriteOptions{Mode: s.mode, Replace: false},
)
if err := errors.Join(writeErr, root.Close()); err != nil {
return err
}
return nil
}
// ReadRoot reads relative inside root. The root confines every path component
// to root, so a replaced parent cannot redirect the read.
func (s Service[T]) ReadRoot(root *os.Root, relative string) (*T, error) {
file, err := root.Open(relative)
if err != nil {
return nil, err
}
value, decodeErr := s.codec.Decode(file)
return value, errors.Join(decodeErr, file.Close())
}
// WriteRoot replaces relative inside root with value and retains the
// permissions of the file it replaces.
func (s Service[T]) WriteRoot(root *os.Root, relative string, value *T) error {
return s.publishRoot(root, relative, value, true)
}
// CreateRoot publishes value at relative inside root only while the name is
// absent. A file that appears after the caller's check survives.
func (s Service[T]) CreateRoot(root *os.Root, relative string, value *T) error {
return s.publishRoot(root, relative, value, false)
}
// publishRoot keeps every operation of one publish on the same root handle.
// Callers use it while they hold the writer lock that SafePath checked.
func (s Service[T]) publishRoot(root *os.Root, relative string, value *T, replace bool) error {
var data []byte
var err error
if replace {
data, err = s.writeRootBytes(root, relative, value)
} else {
data, err = s.Snapshot(value)
}
if err != nil {
return err
}
mode := s.mode
info, statErr := root.Lstat(relative)
switch {
case statErr == nil:
if !info.Mode().IsRegular() {
return fmt.Errorf("write destination must be a regular file")
}
mode = info.Mode().Perm()
case !errors.Is(statErr, os.ErrNotExist):
return statErr
}
err = fsutil.WriteRoot(root, relative, bytes.NewReader(data), fsutil.WriteOptions{
Mode: mode,
Replace: replace,
})
if err != nil {
return err
}
return nil
}
// ManifestFile returns the service for lucy.yaml in root.
func ManifestFile(root string) Service[Manifest] {
service := NewService(filepath.Join(root, ManifestFilename), Codec[Manifest]{
Decode: DecodeManifest,
Encode: (*Manifest).CanonicalBytes,
Merge: func(existing []byte, m *Manifest) ([]byte, error) {
return m.MergeBytes(existing)
},
})
return service
}
// LockFile returns the service for lucy-lock.yaml in root.
func LockFile(root string) Service[Lock] {
service := NewService(filepath.Join(root, LockFilename), Codec[Lock]{
Decode: DecodeLock,
Encode: (*Lock).Snapshot,
Merge: func(existing []byte, l *Lock) ([]byte, error) {
return l.MergeBytes(existing)
},
})
return service
}
package document
import (
"bytes"
"errors"
"fmt"
"reflect"
"strings"
"gopkg.in/yaml.v3"
)
const yamlSchemaDirective = "yaml-language-server:"
// YAMLOptions controls generated YAML comments.
type YAMLOptions struct {
// NoComments writes the document without generated comments.
NoComments bool
// SchemaURL adds the editor schema directive as the document header.
SchemaURL string
}
// Commenter applies the comments declared by serialized types. Descriptions
// and examples belong to the Go types, so no document has a hand-written
// commented template.
type Commenter struct {
options YAMLOptions
}
// NewCommenter configures a commenter for one document family.
func NewCommenter(options YAMLOptions) Commenter {
return Commenter{options: options}
}
// Render writes value as a fresh YAML document.
func (c Commenter) Render(value any) ([]byte, error) {
node, err := c.node(value, true)
if err != nil {
return nil, err
}
return c.marshal(node)
}
// Merge writes value into existing. Comments already present in existing are
// never replaced: new nodes adopt the comments declared by value's type, and
// blank comment slots on existing nodes are filled. A changed description
// therefore reaches new documents and new fields without rewriting the
// comments a user already has.
func (c Commenter) Merge(existing []byte, value any) ([]byte, error) {
incoming, err := c.node(value, false)
if err != nil {
return nil, err
}
var target yaml.Node
if err := yaml.Unmarshal(existing, &target); err != nil {
return nil, fmt.Errorf("decode existing yaml: %w", err)
}
root := documentRoot(&target)
if root == nil {
header := strings.TrimSpace(string(existing))
if header == "" {
return c.marshal(incoming)
}
// A document that held only comments keeps them as a document header,
// independent of the entries written below.
document := &yaml.Node{
Kind: yaml.DocumentNode,
HeadComment: header,
Content: []*yaml.Node{incoming},
}
return c.marshal(document)
}
mergeYAMLNode(root, incoming)
c.removeSchemaDirective(&target)
return c.marshal(&target)
}
// node encodes value and documents its fields. examples selects the template
// form, which also shows omitted fields as commented examples; a merge into an
// existing document documents only the fields it writes.
func (c Commenter) node(value any, examples bool) (*yaml.Node, error) {
var node yaml.Node
if err := node.Encode(value); err != nil {
return nil, err
}
if c.options.NoComments {
return &node, nil
}
t := reflect.TypeOf(value)
if t == nil {
return &node, nil
}
if err := validateYAMLDescriptions(t, map[reflect.Type]bool{}); err != nil {
return nil, err
}
if err := describeYAML(&node, t, examples); err != nil {
return nil, err
}
return &node, nil
}
func (c Commenter) marshal(node *yaml.Node) ([]byte, error) {
if node.Kind == yaml.MappingNode && len(node.Content) == 0 && node.FootComment != "" {
return commentBlock(node.FootComment), nil
}
data, err := marshalYAMLNode(node)
if err != nil {
return nil, err
}
if c.options.NoComments || c.options.SchemaURL == "" {
return data, nil
}
return append([]byte("# yaml-language-server: $schema="+c.options.SchemaURL+"\n"), data...), nil
}
// commentBlock renders a document that contains only generated examples.
func commentBlock(comment string) []byte {
var output strings.Builder
for line := range strings.SplitSeq(strings.TrimRight(comment, "\n"), "\n") {
if !strings.HasPrefix(line, "#") {
output.WriteString("# ")
}
output.WriteString(line)
output.WriteByte('\n')
}
return []byte(output.String())
}
// removeSchemaDirective drops the schema header the previous write added, so
// the next write can place the canonical one. User comments around it stay.
func (c Commenter) removeSchemaDirective(document *yaml.Node) {
if c.options.SchemaURL == "" {
return
}
var walk func(*yaml.Node)
walk = func(node *yaml.Node) {
node.HeadComment = withoutDirective(node.HeadComment)
for _, child := range node.Content {
walk(child)
}
}
walk(document)
}
func withoutDirective(comment string) string {
if comment == "" {
return ""
}
kept := make([]string, 0, strings.Count(comment, "\n")+1)
for line := range strings.SplitSeq(comment, "\n") {
text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#"))
if strings.HasPrefix(text, yamlSchemaDirective) {
continue
}
kept = append(kept, line)
}
return strings.Trim(strings.Join(kept, "\n"), "\n")
}
func documentRoot(node *yaml.Node) *yaml.Node {
if node.Kind == yaml.DocumentNode {
if len(node.Content) == 0 {
return nil
}
return node.Content[0]
}
if node.Kind == 0 {
return nil
}
return node
}
// mergeYAMLNode rewrites existing from incoming. Existing comments always win;
// incoming comments only fill empty slots or accompany new nodes.
func mergeYAMLNode(existing, incoming *yaml.Node) {
fillComments(existing, incoming)
if existing.Kind != incoming.Kind || existing.Tag != incoming.Tag {
replaceNode(existing, incoming)
return
}
switch existing.Kind {
case yaml.DocumentNode:
if len(existing.Content) == 0 || len(incoming.Content) == 0 {
replaceNode(existing, incoming)
return
}
mergeYAMLNode(existing.Content[0], incoming.Content[0])
case yaml.MappingNode:
if len(existing.Content) == 0 {
// An empty flow mapping such as {} carries no layout to preserve.
existing.Style = incoming.Style
}
mergeMappingNode(existing, incoming)
case yaml.SequenceNode:
if len(existing.Content) == 0 {
existing.Style = incoming.Style
}
mergeSequenceNode(existing, incoming)
case yaml.ScalarNode:
existing.Value = incoming.Value
existing.Style = incoming.Style
case yaml.AliasNode:
existing.Alias = incoming.Alias
existing.Value = incoming.Value
}
}
// mergeMappingNode keeps the existing entry order and appends keys the
// document did not have. Entries absent from incoming are removed.
func mergeMappingNode(existing, incoming *yaml.Node) {
pending := make(map[string]int, len(incoming.Content)/2)
for i := 0; i+1 < len(incoming.Content); i += 2 {
pending[incoming.Content[i].Value] = i
}
kept := make([]*yaml.Node, 0, len(existing.Content))
for i := 0; i+1 < len(existing.Content); i += 2 {
key := existing.Content[i]
position, ok := pending[key.Value]
if !ok {
continue
}
mergeYAMLNode(key, incoming.Content[position])
mergeYAMLNode(existing.Content[i+1], incoming.Content[position+1])
kept = append(kept, key, existing.Content[i+1])
delete(pending, key.Value)
}
for i := 0; i+1 < len(incoming.Content); i += 2 {
if _, ok := pending[incoming.Content[i].Value]; !ok {
continue
}
kept = append(kept, cloneYAMLNode(incoming.Content[i]), cloneYAMLNode(incoming.Content[i+1]))
}
existing.Content = kept
}
// mergeSequenceNode merges entries by a stable path key when both sequences
// have one, and by position otherwise. Declarations such as tracked files
// keep their comments when an entry is inserted or removed.
func mergeSequenceNode(existing, incoming *yaml.Node) {
if mergeSequenceByPath(existing, incoming) {
return
}
count := min(len(existing.Content), len(incoming.Content))
for i := range count {
mergeYAMLNode(existing.Content[i], incoming.Content[i])
}
if len(incoming.Content) > len(existing.Content) {
for _, node := range incoming.Content[count:] {
existing.Content = append(existing.Content, cloneYAMLNode(node))
}
return
}
existing.Content = existing.Content[:count]
}
// mergeSequenceByPath reports whether it merged the sequences. It requires
// every entry on both sides to be a mapping with a unique, non-empty string
// path, which is the identity tracked files and lock entries declare.
func mergeSequenceByPath(existing, incoming *yaml.Node) bool {
if _, ok := sequencePathIndex(existing); !ok {
return false
}
incomingIndex, ok := sequencePathIndex(incoming)
if !ok {
return false
}
kept := make([]*yaml.Node, 0, len(existing.Content))
for _, entry := range existing.Content {
path, _ := sequenceEntryPath(entry)
position, ok := incomingIndex[path]
if !ok {
continue
}
mergeYAMLNode(entry, incoming.Content[position])
kept = append(kept, entry)
delete(incomingIndex, path)
}
for _, entry := range incoming.Content {
path, _ := sequenceEntryPath(entry)
if _, ok := incomingIndex[path]; !ok {
continue
}
kept = append(kept, cloneYAMLNode(entry))
}
existing.Content = kept
return true
}
func sequencePathIndex(node *yaml.Node) (map[string]int, bool) {
if node.Kind != yaml.SequenceNode {
return nil, false
}
index := make(map[string]int, len(node.Content))
for i, entry := range node.Content {
path, ok := sequenceEntryPath(entry)
if !ok {
return nil, false
}
if _, exists := index[path]; exists {
return nil, false
}
index[path] = i
}
return index, true
}
func sequenceEntryPath(entry *yaml.Node) (string, bool) {
if entry.Kind != yaml.MappingNode {
return "", false
}
for i := 0; i+1 < len(entry.Content); i += 2 {
key, value := entry.Content[i], entry.Content[i+1]
if key.Value != "path" {
continue
}
if value.Kind != yaml.ScalarNode || value.Tag != "!!str" || value.Value == "" {
return "", false
}
return value.Value, true
}
return "", false
}
func fillComments(existing, incoming *yaml.Node) {
if existing.HeadComment == "" {
existing.HeadComment = incoming.HeadComment
}
if existing.LineComment == "" {
existing.LineComment = incoming.LineComment
}
if existing.FootComment == "" {
existing.FootComment = incoming.FootComment
}
}
// replaceNode adopts incoming's shape while keeping every comment existing
// carried.
func replaceNode(existing, incoming *yaml.Node) {
head, line, foot := existing.HeadComment, existing.LineComment, existing.FootComment
*existing = *cloneYAMLNode(incoming)
if head != "" {
existing.HeadComment = head
}
if line != "" {
existing.LineComment = line
}
if foot != "" {
existing.FootComment = foot
}
}
func cloneYAMLNode(node *yaml.Node) *yaml.Node {
copy := *node
copy.Content = make([]*yaml.Node, len(node.Content))
for i, child := range node.Content {
copy.Content[i] = cloneYAMLNode(child)
}
return ©
}
func marshalYAMLNode(node *yaml.Node) ([]byte, error) {
var output bytes.Buffer
encoder := yaml.NewEncoder(&output)
encoder.SetIndent(2)
encodeErr := encoder.Encode(node)
return output.Bytes(), errors.Join(encodeErr, encoder.Close())
}
func yamlFieldName(field reflect.StructField) string {
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
return name
}
func validateYAMLDescriptions(t reflect.Type, seen map[reflect.Type]bool) error {
if t == nil || seen[t] {
return nil
}
seen[t] = true
switch t.Kind() {
case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map:
return validateYAMLDescriptions(t.Elem(), seen)
case reflect.Struct:
for i := range t.NumField() {
field := t.Field(i)
if !field.IsExported() || yamlFieldName(field) == "-" {
continue
}
if yamlFieldName(field) == "" || field.Tag.Get("description") == "" {
return fmt.Errorf("yaml field %s.%s requires a name and description", t.Name(), field.Name)
}
if err := validateYAMLDescriptions(field.Type, seen); err != nil {
return err
}
}
}
return nil
}
func describeYAML(node *yaml.Node, t reflect.Type, examples bool) error {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
switch t.Kind() {
case reflect.Struct:
if node.Kind != yaml.MappingNode {
return nil // Custom marshalers can select a scalar shorthand.
}
var pending strings.Builder
for i := range t.NumField() {
field := t.Field(i)
if !field.IsExported() || yamlFieldName(field) == "-" {
continue
}
name := yamlFieldName(field)
index := yamlKeyIndex(node, name)
if index < 0 {
if !examples {
continue
}
example, err := yamlFieldExample(field)
if err != nil {
return err
}
if example == nil {
continue
}
mapping := &yaml.Node{
Kind: yaml.MappingNode,
Content: []*yaml.Node{{Kind: yaml.ScalarNode, Tag: "!!str", Value: name}, example},
}
data, err := marshalYAMLNode(mapping)
if err != nil {
return err
}
pending.WriteString(field.Tag.Get("description") + "\n")
pending.Write(data)
continue
}
key, child := node.Content[index], node.Content[index+1]
key.HeadComment = pending.String() + field.Tag.Get("description")
pending.Reset()
if err := describeYAML(child, field.Type, examples); err != nil {
return err
}
}
if pending.Len() != 0 {
if len(node.Content) == 0 {
node.FootComment = strings.TrimRight(pending.String(), "\n")
} else {
// A mapping's own foot comment escapes the indentation of its
// fields, while a key's foot comment stays in the mapping.
key := node.Content[len(node.Content)-2]
key.FootComment = strings.TrimRight(pending.String(), "\n")
}
}
case reflect.Map:
if node.Kind == yaml.MappingNode {
for i := 1; i < len(node.Content); i += 2 {
if err := describeYAML(node.Content[i], t.Elem(), examples); err != nil {
return err
}
}
}
case reflect.Slice, reflect.Array:
if node.Kind == yaml.SequenceNode {
for _, child := range node.Content {
if err := describeYAML(child, t.Elem(), examples); err != nil {
return err
}
}
}
}
return nil
}
func yamlKeyIndex(node *yaml.Node, name string) int {
for i := 0; i < len(node.Content); i += 2 {
if node.Content[i].Value == name {
return i
}
}
return -1
}
// yamlFieldExample builds the commented example a template shows for an
// omitted field. An example tag of "-" suppresses that example, so the field
// appears only once the document carries it.
func yamlFieldExample(field reflect.StructField) (*yaml.Node, error) {
if example, ok := field.Tag.Lookup("example"); ok {
if example == "-" {
return nil, nil
}
var node yaml.Node
if err := yaml.Unmarshal([]byte(example), &node); err != nil {
return nil, fmt.Errorf("yaml example for %s: %w", field.Name, err)
}
if len(node.Content) != 0 {
return node.Content[0], nil
}
}
t := field.Type
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
node := &yaml.Node{}
switch t.Kind() {
case reflect.Struct:
node.Kind = yaml.MappingNode
for i := range t.NumField() {
child := t.Field(i)
if !child.IsExported() || yamlFieldName(child) == "-" {
continue
}
example, err := yamlFieldExample(child)
if err != nil {
return nil, err
}
if example == nil {
continue
}
node.Content = append(node.Content, &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: yamlFieldName(child),
HeadComment: child.Tag.Get("description"),
}, example)
}
case reflect.Map:
node.Kind = yaml.MappingNode
case reflect.Slice, reflect.Array:
node.Kind = yaml.SequenceNode
default:
if err := node.Encode(reflect.Zero(t).Interface()); err != nil {
return nil, err
}
}
return node, nil
}
// Package testkit resolves the repository paths and prerequisites that the e2e
// suites run against.
//
// Each suite is a test binary under the e2e build tag, so this package is linked
// only there. The flags below replace the per-suite command-line flags that the
// suites needed while they were commands.
package testkit
import (
"flag"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
)
var (
lucyBinary = flag.String("lucy", "", "path to the Lucy binary (default: dist/lucy under the repository root)")
sandboxDir = flag.String("sandboxes", "", "directory holding generated sandboxes (default: .sandboxes under the repository root)")
)
var (
rootOnce sync.Once
rootPath string
rootErr error
)
// RepoRoot returns the repository root that contains the running suite.
func RepoRoot(t *testing.T) string {
t.Helper()
rootOnce.Do(func() { rootPath, rootErr = resolveRoot() })
if rootErr != nil {
t.Fatalf("resolve repository root: %v", rootErr)
}
return rootPath
}
// resolveRoot locates the repository from this source file. Tests run with the
// package directory as their working directory, so the source location is the
// only path that stays stable across suites.
func resolveRoot() (string, error) {
_, source, _, ok := runtime.Caller(0)
if !ok {
return "", fmt.Errorf("source location unavailable")
}
for dir := filepath.Dir(source); ; {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("no go.mod above %s", source)
}
dir = parent
}
}
// Lucy returns the absolute path to the Lucy binary that the suites invoke. It
// fails the test when the binary is absent so a suite never reports success
// without running the CLI it verifies.
func Lucy(t *testing.T) string {
t.Helper()
path := *lucyBinary
if path == "" {
path = filepath.Join(RepoRoot(t), "dist", "lucy")
}
absolute, err := filepath.Abs(path)
if err != nil {
t.Fatalf("resolve Lucy binary: %v", err)
}
info, err := os.Stat(absolute)
if err != nil {
t.Fatalf("Lucy binary unavailable (%s): build it with `task build` first: %v", absolute, err)
}
if info.IsDir() {
t.Fatalf("Lucy binary is a directory: %s", absolute)
}
return absolute
}
// Sandboxes returns the absolute directory that holds generated sandboxes.
func Sandboxes(t *testing.T) string {
t.Helper()
path := *sandboxDir
if path == "" {
path = filepath.Join(RepoRoot(t), ".sandboxes")
}
absolute, err := filepath.Abs(path)
if err != nil {
t.Fatalf("resolve sandbox directory: %v", err)
}
return absolute
}
// Sandbox returns the generated sandbox directory for id. It fails the test when
// the directory is absent, so a scenario cannot pass by finding nothing to probe.
func Sandbox(t *testing.T, id string) string {
t.Helper()
dir := filepath.Join(Sandboxes(t), id)
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
t.Fatalf("sandbox %s is not generated (%s): run `task sandboxes:gen -- --only %s`", id, dir, id)
}
return dir
}
// CloneSandbox copies a generated sandbox into a temporary directory. Files
// hardlink when the filesystem allows it; a clone never modifies existing files.
func CloneSandbox(t *testing.T, id string) string {
t.Helper()
source := Sandbox(t, id)
root := t.TempDir()
err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
relative, err := filepath.Rel(source, path)
if err != nil {
return err
}
target := filepath.Join(root, relative)
if entry.IsDir() {
return os.MkdirAll(target, 0o755)
}
if err := os.Link(path, target); err == nil {
return nil
}
return copyFile(path, target)
})
if err != nil {
t.Fatalf("clone sandbox %s: %v", id, err)
}
return root
}
func copyFile(source, target string) error {
in, err := os.Open(source)
if err != nil {
return err
}
defer func() { _ = in.Close() }()
out, err := os.Create(target)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
// HasSandbox reports whether the generated sandbox directory for id exists.
func HasSandbox(t *testing.T, id string) bool {
t.Helper()
info, err := os.Stat(filepath.Join(Sandboxes(t), id))
return err == nil && info.IsDir()
}
// Command sandbox materializes server sandboxes from e2e/testdata/sandboxes.
package main
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"encoding/json/v2"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/mclucy/lucy/internal/toolchain"
"gopkg.in/yaml.v3"
)
const userAgent = "lucy-sandbox/1.0 (+https://github.com/mclucy/lucy)"
type manifest struct {
Version int `yaml:"version"`
Sandboxes []sandbox `yaml:"sandboxes"`
}
// docsRefs accepts either a scalar string or a sequence of strings in YAML.
type docsRefs []string
func (d *docsRefs) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
*d = []string{node.Value}
case yaml.SequenceNode:
var items []string
if err := node.Decode(&items); err != nil {
return err
}
*d = items
default:
return fmt.Errorf("docs must be a string or a list of strings")
}
return nil
}
type sandbox struct {
ID string `yaml:"id"`
Family string `yaml:"family"`
GameVersion string `yaml:"game_version"`
Description string `yaml:"description"`
Docs docsRefs `yaml:"docs,omitempty"`
// EntryPoint is the launch target a sandbox must contain. An
// installer-backed sandbox must produce it; a standalone sandbox pins it
// directly. It is relative to the sandbox root.
EntryPoint string `yaml:"entry_point,omitempty"`
Dirs []string `yaml:"dirs"`
Artifacts []artifact `yaml:"artifacts"`
}
type artifact struct {
Dest string `yaml:"dest"`
// SHA256 pins the expected content digest. Optional: without it, digest
// verification is skipped.
SHA256 string `yaml:"sha256"`
URL string `yaml:"url,omitempty"`
MojangVersion string `yaml:"mojang_version,omitempty"`
FabricMeta *fabricMeta `yaml:"fabric_meta,omitempty"`
Purpur *purpurSpec `yaml:"purpur,omitempty"`
Manual bool `yaml:"manual,omitempty"`
// Modpack marks a Modrinth pack archive. Generation writes the indexed
// server files and the pack overrides into the sandbox root instead of
// copying the archive.
Modpack bool `yaml:"modpack,omitempty"`
// InstallServer marks an official Forge/NeoForge installer. Generation
// runs it with --installServer in the directory that holds dest, after
// every artifact and fixture file has landed, so the sandbox carries the
// generated launch target rather than only copied libraries.
InstallServer bool `yaml:"install_server,omitempty"`
}
type fabricMeta struct {
Game string `yaml:"game"`
Loader string `yaml:"loader"`
Installer string `yaml:"installer"`
}
type purpurSpec struct {
Version string `yaml:"version"`
Build string `yaml:"build"`
}
type options struct {
manifestPath string
outRoot string
cacheDir string
manualDir string
fixturesRoot string
only string
force bool
list bool
}
func main() {
opts := options{}
flag.StringVar(
&opts.manifestPath,
"manifest",
"e2e/testdata/sandboxes/sandboxes.yaml",
"path to the sandboxes manifest",
)
flag.StringVar(&opts.outRoot, "out", ".sandboxes", "output root for generated sandboxes")
defaultCache, _ := os.UserCacheDir()
flag.StringVar(&opts.cacheDir, "cache", filepath.Join(defaultCache, "lucy-sandbox"), "download cache directory")
flag.StringVar(&opts.manualDir, "manual-dir", "", "directory holding manually supplied artifacts (default <cache>/manual)")
flag.StringVar(&opts.only, "only", "", "comma-separated sandbox ids to process")
flag.BoolVar(&opts.force, "force", false, "regenerate even if the output directory already exists")
flag.BoolVar(&opts.list, "list", false, "list sandboxes and exit")
flag.Parse()
if opts.manualDir == "" {
opts.manualDir = filepath.Join(opts.cacheDir, "manual")
}
opts.fixturesRoot = filepath.Join(filepath.Dir(opts.manifestPath), "fixtures")
data, err := os.ReadFile(opts.manifestPath)
if err != nil {
fatal("read manifest: %v", err)
}
var man manifest
if err := yaml.Unmarshal(data, &man); err != nil {
fatal("parse manifest: %v", err)
}
if err := validate(&man); err != nil {
fatal("invalid manifest: %v", err)
}
if opts.list {
listSandboxes(&man, opts.outRoot)
return
}
if err := run(&man, opts); err != nil {
fatal("%v", err)
}
}
func validate(man *manifest) error {
seen := map[string]bool{}
for _, sb := range man.Sandboxes {
if !filepath.IsLocal(sb.ID) || sb.ID == "." {
return fmt.Errorf("invalid sandbox id %q: must be a local path below the output root", sb.ID)
}
if seen[sb.ID] {
return fmt.Errorf("duplicate sandbox id %q", sb.ID)
}
seen[sb.ID] = true
installers := 0
for _, art := range sb.Artifacts {
if art.Dest == "" {
return fmt.Errorf("%s: artifact without dest", sb.ID)
}
if art.SHA256 != "" {
if len(art.SHA256) != 64 {
return fmt.Errorf("%s/%s: sha256 must be 64 hex chars", sb.ID, art.Dest)
}
if _, err := hex.DecodeString(art.SHA256); err != nil {
return fmt.Errorf("%s/%s: bad sha256: %v", sb.ID, art.Dest, err)
}
}
kinds := 0
for _, set := range []bool{art.URL != "", art.MojangVersion != "", art.FabricMeta != nil, art.Purpur != nil, art.Manual} {
if set {
kinds++
}
}
if kinds != 1 {
return fmt.Errorf(
"%s/%s: exactly one of url, mojang_version, fabric_meta, purpur, manual required",
sb.ID, art.Dest,
)
}
if art.Modpack && (art.URL == "" || art.SHA256 == "") {
return fmt.Errorf("%s/%s: modpack requires url and sha256", sb.ID, art.Dest)
}
if art.InstallServer {
installers++
if art.Modpack {
return fmt.Errorf("%s/%s: an installer cannot be a modpack", sb.ID, art.Dest)
}
if !strings.HasSuffix(strings.ToLower(art.Dest), ".jar") {
return fmt.Errorf("%s/%s: installer dest must be a jar", sb.ID, art.Dest)
}
}
}
if installers > 1 {
return fmt.Errorf("%s: at most one install_server artifact is supported", sb.ID)
}
hasEntryPoint := sb.EntryPoint != ""
if installers == 1 && !hasEntryPoint {
return fmt.Errorf("%s: install_server requires entry_point", sb.ID)
}
if hasEntryPoint {
entryPoint := filepath.FromSlash(sb.EntryPoint)
if !filepath.IsLocal(entryPoint) || entryPoint == "." {
return fmt.Errorf("%s: entry_point must be a local path below the sandbox root", sb.ID)
}
}
}
return nil
}
func listSandboxes(man *manifest, outRoot string) {
fmt.Printf("%-24s %-14s %-10s %-10s %s\n", "ID", "FAMILY", "GAME", "STATE", "DESCRIPTION")
for _, sb := range man.Sandboxes {
state := "missing"
if _, err := os.Stat(filepath.Join(outRoot, sb.ID)); err == nil {
state = "generated"
}
manual := ""
for _, art := range sb.Artifacts {
if art.Manual {
manual = " (manual)"
break
}
}
fmt.Printf("%-24s %-14s %-10s %-10s %s%s\n",
sb.ID, sb.Family, sb.GameVersion, state, sb.Description, manual)
}
}
func run(man *manifest, opts options) error {
selected := map[string]bool{}
if opts.only != "" {
for id := range strings.SplitSeq(opts.only, ",") {
selected[strings.TrimSpace(id)] = true
}
}
if err := os.MkdirAll(opts.cacheDir, 0o755); err != nil {
return err
}
var failed []string
for _, sb := range man.Sandboxes {
if len(selected) > 0 && !selected[sb.ID] {
continue
}
if err := generateSandbox(sb, opts); err != nil {
fmt.Fprintf(os.Stderr, "[FAIL] %s: %v\n", sb.ID, err)
failed = append(failed, sb.ID)
continue
}
}
if len(failed) > 0 {
return fmt.Errorf("failed sandboxes: %s", strings.Join(failed, ", "))
}
return nil
}
type resolvedArtifact struct {
artifact
cachedPath string
}
func generateSandbox(sb sandbox, opts options) error {
outDir := filepath.Join(opts.outRoot, sb.ID)
if _, err := os.Stat(outDir); err == nil && !opts.force {
fmt.Printf("[SKIP] %s (exists; use --force)\n", sb.ID)
return nil
}
resolvedArtifacts := make([]resolvedArtifact, 0, len(sb.Artifacts))
for _, art := range sb.Artifacts {
path, err := ensureCached(sb.ID, art, opts)
if err != nil {
return fmt.Errorf("artifact %s: %w", art.Dest, err)
}
resolvedArtifacts = append(resolvedArtifacts, resolvedArtifact{artifact: art, cachedPath: path})
fmt.Printf("[OK] %s: %s\n", sb.ID, art.Dest)
}
if err := os.MkdirAll(opts.outRoot, 0o755); err != nil {
return fmt.Errorf("create output root: %w", err)
}
stage, err := os.MkdirTemp(opts.outRoot, ".sandbox-*")
if err != nil {
return fmt.Errorf("create staging directory: %w", err)
}
if err := os.Chmod(stage, 0o755); err != nil {
return err
}
published := false
defer func() {
if !published {
_ = os.RemoveAll(stage)
}
}()
for _, dir := range sb.Dirs {
if err := os.MkdirAll(filepath.Join(stage, dir), 0o755); err != nil {
return err
}
}
for _, ra := range resolvedArtifacts {
if ra.Modpack {
if err := materializeModpack(ra.cachedPath, stage, opts); err != nil {
return fmt.Errorf("materialize modpack %s: %w", ra.Dest, err)
}
continue
}
dest := filepath.Join(stage, ra.Dest)
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
if err := copyFile(ra.cachedPath, dest); err != nil {
return fmt.Errorf("install %s: %w", ra.Dest, err)
}
}
fixturesDir := filepath.Join(opts.fixturesRoot, sb.ID)
if _, err := os.Stat(fixturesDir); err == nil {
if err := copyTree(fixturesDir, stage); err != nil {
return fmt.Errorf("copy fixtures: %w", err)
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
for _, ra := range resolvedArtifacts {
if !ra.InstallServer {
continue
}
if err := installServer(stage, ra); err != nil {
return fmt.Errorf("%s: %w", sb.ID, err)
}
fmt.Printf("[INST] %s: %s\n", sb.ID, ra.Dest)
}
if sb.EntryPoint != "" {
if err := verifyEntryPoint(stage, sb.EntryPoint); err != nil {
return fmt.Errorf("%s: %w", sb.ID, err)
}
}
// Publish only a complete, validated fixture. A failed download or
// installer run leaves no directory that a later run would skip.
if err := os.RemoveAll(outDir); err != nil {
return fmt.Errorf("replace %s: %w", outDir, err)
}
if err := os.MkdirAll(filepath.Dir(outDir), 0o755); err != nil {
return err
}
if err := os.Rename(stage, outDir); err != nil {
return fmt.Errorf("publish %s: %w", outDir, err)
}
published = true
fmt.Printf("[GEN] %s -> %s\n", sb.ID, outDir)
return nil
}
// installerJavaMinimum is the oldest runtime that the pinned Forge and
// NeoForge installers support. The generator only runs the installer; it never
// launches the server, so the game version's own Java advisory does not apply.
const installerJavaMinimum = 17
// installServer runs one pinned loader installer in the directory that holds
// it. The installer is the only source of the generated argument sidecars,
// shim, and patched server jar; a copied universal jar is a runtime library,
// not a launch target.
func installServer(outDir string, ra resolvedArtifact) error {
java, err := installerJava()
if err != nil {
return fmt.Errorf("installer %s: %w", ra.Dest, err)
}
target := filepath.Dir(filepath.Join(outDir, ra.Dest))
// Run the copied installer under its fixture name so its own log and any
// path it derives match a real `java -jar <installer> --installServer .`.
cmd := exec.Command(java, "-jar", filepath.Base(ra.Dest), "--installServer", ".")
cmd.Dir = target
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
if err := cmd.Run(); err != nil {
return fmt.Errorf("installer %s failed: %w\n%s", ra.Dest, err, outputTail(output.String()))
}
return nil
}
// installerJava picks the oldest installed runtime that the installers
// support. Preferring the oldest qualifying major keeps legacy installers off
// newer class-file versions while still accepting the current JDK.
func installerJava() (string, error) {
inventory, err := toolchain.Discover(context.Background())
if err != nil {
return "", fmt.Errorf("discover Java runtimes: %w", err)
}
var chosen *toolchain.JavaInstallation
for index := range inventory.Java {
runtime := &inventory.Java[index]
if runtime.Major < installerJavaMinimum {
continue
}
if chosen == nil || runtime.Major < chosen.Major {
chosen = runtime
}
}
if chosen == nil {
return "", fmt.Errorf(
"no Java %d+ runtime found: install a JDK %d or newer and put java on PATH or set JAVA_HOME",
installerJavaMinimum, installerJavaMinimum,
)
}
return chosen.Java, nil
}
func outputTail(output string) string {
const limit = 8 << 10
output = strings.TrimSpace(output)
if len(output) <= limit {
return output
}
return "..." + output[len(output)-limit:]
}
// verifyEntryPoint proves that an installer produced its declared launch
// target, or that a pinned standalone launch jar is present and bootable.
func verifyEntryPoint(outDir, entryPoint string) error {
path := filepath.Join(outDir, filepath.FromSlash(entryPoint))
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("launch target %s is missing: %w", entryPoint, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("launch target %s is not a regular file", entryPoint)
}
if info.Size() == 0 {
return fmt.Errorf("launch target %s is empty", entryPoint)
}
if !strings.HasSuffix(strings.ToLower(entryPoint), ".jar") {
return nil
}
archive, err := zip.OpenReader(path)
if err != nil {
return fmt.Errorf("launch target %s is not a readable jar: %w", entryPoint, err)
}
defer func() { _ = archive.Close() }()
if len(archive.File) == 0 {
return fmt.Errorf("launch target %s is an empty jar", entryPoint)
}
manifest, err := readZipEntry(&archive.Reader, "META-INF/MANIFEST.MF")
if err != nil {
return fmt.Errorf("launch target %s has no manifest: %w", entryPoint, err)
}
if manifestMainClass(manifest) == "" {
return fmt.Errorf("launch target %s declares no Main-Class", entryPoint)
}
return nil
}
// manifestMainClass returns the declared Main-Class of a jar manifest, or ""
// when the attribute is absent. The manifest format is line oriented; a
// Main-Class value is short enough that Forge and NeoForge never fold it.
func manifestMainClass(manifest []byte) string {
for line := range strings.SplitSeq(string(manifest), "\n") {
key, value, ok := strings.Cut(strings.TrimSuffix(line, "\r"), ":")
if !ok || !strings.EqualFold(strings.TrimSpace(key), "Main-Class") {
continue
}
return strings.TrimSpace(value)
}
return ""
}
// modpackIndex is the subset of modrinth.index.json that server generation
// needs. Client-only entries carry env.server "unsupported".
type modpackIndex struct {
Files []struct {
Path string `json:"path"`
Hashes struct {
SHA512 string `json:"sha512"`
} `json:"hashes"`
Env struct {
Server string `json:"server"`
} `json:"env"`
Downloads []string `json:"downloads"`
} `json:"files"`
}
// materializeModpack writes the server side of a Modrinth pack into outDir:
// every indexed server file lands at its declared path, then overrides and
// server-overrides unpack at the root.
func materializeModpack(packPath, outDir string, opts options) error {
archive, err := zip.OpenReader(packPath)
if err != nil {
return err
}
defer func() { _ = archive.Close() }()
data, err := readZipEntry(&archive.Reader, "modrinth.index.json")
if err != nil {
return fmt.Errorf("read pack index: %w", err)
}
var index modpackIndex
if err := json.Unmarshal(data, &index); err != nil {
return fmt.Errorf("decode pack index: %w", err)
}
for _, file := range index.Files {
// Create+ 6.0.0 Alpha f marks one client-only jar as "unknown" for both
// sides, which the pack format does not define. Skip it until the
// upstream metadata is corrected: the jar crashes a dedicated server.
// https://github.com/Sshmoob/Create-Plus/issues/80
if file.Env.Server == "unsupported" || file.Env.Server == "unknown" {
continue
}
if len(file.Downloads) == 0 {
return fmt.Errorf("pack file %s has no download", file.Path)
}
cached, err := fetchDigest(file.Downloads[0], file.Hashes.SHA512, opts)
if err != nil {
return fmt.Errorf("pack file %s: %w", file.Path, err)
}
if err := writePackFile(outDir, file.Path, func(dest string) error {
return copyFile(cached, dest)
}); err != nil {
return fmt.Errorf("pack file %s: %w", file.Path, err)
}
}
// The pack format applies generic overrides first, then server overrides.
// Archive order carries no layer meaning, so the two prefixes get their own
// pass; a server file for a path always wins.
for _, prefix := range []string{"overrides/", "server-overrides/"} {
for _, entry := range archive.File {
name, ok := strings.CutPrefix(entry.Name, prefix)
if !ok || name == "" {
continue
}
if err := writePackFile(outDir, name, func(dest string) error {
if entry.FileInfo().IsDir() {
return os.MkdirAll(dest, 0o755)
}
return extractZipEntry(entry, dest)
}); err != nil {
return err
}
}
}
return nil
}
// writePackFile resolves a pack-relative path below outDir, creates its parent
// directory, then hands the destination to write. Pack paths use forward
// slashes and must stay inside the sandbox root, so this is the single guard
// between archive content and the filesystem.
func writePackFile(outDir, name string, write func(string) error) error {
relative := filepath.FromSlash(name)
if relative == "." || strings.Contains(name, `\`) || !filepath.IsLocal(relative) {
return fmt.Errorf("unsafe pack path %q", name)
}
dest := filepath.Join(outDir, relative)
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
return write(dest)
}
func readZipEntry(reader *zip.Reader, name string) ([]byte, error) {
file, err := reader.Open(name)
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
return io.ReadAll(file)
}
func extractZipEntry(entry *zip.File, dest string) error {
source, err := entry.Open()
if err != nil {
return err
}
defer func() { _ = source.Close() }()
mode := entry.Mode().Perm()
if mode == 0 {
mode = 0o644
}
target, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err := io.Copy(target, source); err != nil {
_ = target.Close()
return err
}
return target.Close()
}
// fetchDigest caches one pack file under its sha512 digest. The digest is
// mandatory: pack indexes always publish one, and it is the only integrity
// check for downloads that do not share the sandbox manifest.
func fetchDigest(url, sha512Hex string, opts options) (string, error) {
key := strings.ToLower(sha512Hex)
if len(key) != 128 {
return "", fmt.Errorf("pack file has no usable sha512")
}
if _, err := hex.DecodeString(key); err != nil {
return "", fmt.Errorf("bad sha512: %w", err)
}
cached := filepath.Join(opts.cacheDir, key[:2], key)
if _, err := os.Stat(cached); err == nil {
return cached, nil
}
if err := os.MkdirAll(filepath.Dir(cached), 0o755); err != nil {
return "", err
}
tmp, err := os.CreateTemp(opts.cacheDir, "modpack-*")
if err != nil {
return "", err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if err := downloadTo(url, tmp); err != nil {
_ = tmp.Close()
return "", err
}
if err := tmp.Close(); err != nil {
return "", err
}
digest, err := fileSHA512(tmpName)
if err != nil {
return "", err
}
if digest != key {
return "", fmt.Errorf("sha512 mismatch: got %s want %s", digest, key)
}
if err := os.Rename(tmpName, cached); err != nil {
return "", err
}
return cached, nil
}
func fileSHA512(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
h := sha512.New()
if _, err := io.Copy(h, file); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func ensureCached(sandboxID string, art artifact, opts options) (string, error) {
key := strings.ToLower(art.SHA256)
if key == "" {
sum := sha256.Sum256([]byte(sourceDescriptor(sandboxID, art)))
key = "src-" + hex.EncodeToString(sum[:])
}
cached := filepath.Join(opts.cacheDir, key[:2], key)
if _, err := os.Stat(cached); err == nil {
return cached, nil
}
if err := os.MkdirAll(filepath.Dir(cached), 0o755); err != nil {
return "", err
}
tmp, err := os.CreateTemp(opts.cacheDir, "download-*")
if err != nil {
return "", err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
switch {
case art.Manual:
_ = tmp.Close()
source := filepath.Join(opts.manualDir, sandboxID, filepath.Base(art.Dest))
if _, err := os.Stat(source); err != nil {
if art.SHA256 == "" {
return "", fmt.Errorf("manual artifact missing: place the file at %s", source)
}
return "", fmt.Errorf(
"manual artifact missing: place the file at %s (expected sha256 %s)",
source, key,
)
}
if err := copyFile(source, tmpName); err != nil {
return "", err
}
case art.URL != "":
if err := downloadTo(art.URL, tmp); err != nil {
_ = tmp.Close()
return "", err
}
_ = tmp.Close()
case art.MojangVersion != "":
url, err := resolveMojangServer(art.MojangVersion)
if err != nil {
_ = tmp.Close()
return "", err
}
if err := downloadTo(url, tmp); err != nil {
_ = tmp.Close()
return "", err
}
_ = tmp.Close()
case art.FabricMeta != nil:
url := fmt.Sprintf(
"https://meta.fabricmc.net/v2/versions/loader/%s/%s/%s/server/jar",
art.FabricMeta.Game, art.FabricMeta.Loader, art.FabricMeta.Installer,
)
if err := downloadTo(url, tmp); err != nil {
_ = tmp.Close()
return "", err
}
_ = tmp.Close()
case art.Purpur != nil:
url := fmt.Sprintf(
"https://api.purpurmc.org/v2/purpur/%s/%s/download",
art.Purpur.Version, art.Purpur.Build,
)
if err := downloadTo(url, tmp); err != nil {
_ = tmp.Close()
return "", err
}
_ = tmp.Close()
}
if art.SHA256 != "" {
digest, err := fileSHA256(tmpName)
if err != nil {
return "", err
}
if digest != key {
return "", fmt.Errorf("sha256 mismatch: got %s want %s", digest, key)
}
}
if err := os.Rename(tmpName, cached); err != nil {
return "", err
}
return cached, nil
}
// sourceDescriptor names where an artifact's bytes come from. Digest-less
// artifacts key their cache entries by the hash of this descriptor, prefixed
// src- to keep them disjoint from content digests.
func sourceDescriptor(sandboxID string, art artifact) string {
switch {
case art.URL != "":
return art.URL
case art.MojangVersion != "":
return "mojang:" + art.MojangVersion
case art.FabricMeta != nil:
return fmt.Sprintf(
"fabric:%s/%s/%s",
art.FabricMeta.Game, art.FabricMeta.Loader, art.FabricMeta.Installer,
)
case art.Purpur != nil:
return fmt.Sprintf("purpur:%s/%s", art.Purpur.Version, art.Purpur.Build)
default:
return "manual:" + sandboxID + "/" + filepath.Base(art.Dest)
}
}
func resolveMojangServer(version string) (string, error) {
metaURL := "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
var manifestResp struct {
Versions []struct {
ID string `json:"id"`
URL string `json:"url"`
} `json:"versions"`
}
if err := getJSON(metaURL, &manifestResp); err != nil {
return "", fmt.Errorf("mojang manifest: %w", err)
}
var entryURL string
for _, v := range manifestResp.Versions {
if v.ID == version {
entryURL = v.URL
break
}
}
if entryURL == "" {
return "", fmt.Errorf("mojang version %q not found", version)
}
var versionJSON struct {
Downloads map[string]struct {
URL string `json:"url"`
} `json:"downloads"`
}
if err := getJSON(entryURL, &versionJSON); err != nil {
return "", fmt.Errorf("mojang version %s: %w", version, err)
}
server, ok := versionJSON.Downloads["server"]
if !ok || server.URL == "" {
return "", fmt.Errorf("mojang version %s has no server download", version)
}
return server.URL, nil
}
func getJSON(url string, target any) error {
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Get(url)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
}
return json.UnmarshalRead(resp.Body, target)
}
func downloadTo(url string, tmp *os.File) error {
client := &http.Client{Timeout: 5 * time.Minute}
var lastErr error
for range 3 {
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
return err
}
if err := tmp.Truncate(0); err != nil {
return err
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err == nil {
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
lastErr = fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
continue
}
_, err = io.Copy(tmp, resp.Body)
_ = resp.Body.Close()
if err == nil {
return nil
}
lastErr = err
} else {
lastErr = err
}
}
return lastErr
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer func() { _ = in.Close() }()
tmpDst := dst + ".sandbox-tmp"
out, err := os.Create(tmpDst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
_ = os.Remove(tmpDst)
return err
}
if err := out.Close(); err != nil {
_ = os.Remove(tmpDst)
return err
}
return os.Rename(tmpDst, dst)
}
func copyTree(srcRoot, dstRoot string) error {
return filepath.WalkDir(srcRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(srcRoot, path)
if err != nil {
return err
}
target := filepath.Join(dstRoot, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(path, target)
})
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "sandbox: "+format+"\n", args...)
os.Exit(1)
}
package filetrack
import (
"bytes"
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/mclucy/lucy/cas"
"github.com/mclucy/lucy/catalog"
documentpkg "github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/internal/workspacewrite"
"github.com/mclucy/lucy/log"
serverpkg "github.com/mclucy/lucy/server"
"gopkg.in/yaml.v3"
)
// Service executes file tracking with an explicit HTTP client and diagnostic
// logger. The client is used only for declared recovery sources; tracking,
// storage, and transaction safety stay local.
type Service struct {
client *http.Client
logger *log.Logger
}
// NewService constructs a file tracking service. client performs recovery
// downloads; logger records reclamation and recovery diagnostics.
func NewService(client *http.Client, logger *log.Logger) *Service {
return &Service{client: client, logger: logger}
}
// Run prepares every selected file before making any accepted-state change.
func (s *Service) Run(ctx context.Context, root string, request Request) (result Result, resultErr error) {
if err := validateRequest(request); err != nil {
return result, err
}
result.Event.Operation = request.Operation
root, err := filepath.Abs(root)
if err != nil {
return result, err
}
lock, err := workspacewrite.Lock(root, s.logger)
if err != nil {
return result, err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
if err := RecoverLocked(root, s.logger); err != nil {
return result, err
}
files, err := os.OpenRoot(root)
if err != nil {
return result, err
}
defer func() { resultErr = errors.Join(resultErr, files.Close()) }()
store, err := cas.New(filepath.Join(root, ".lucy"))
if err != nil {
return result, err
}
defer func() { resultErr = errors.Join(resultErr, store.Close()) }()
if err := store.Cleanup(); err != nil {
return result, err
}
events, historySize, err := readHistory(root, true)
if err != nil {
return result, err
}
beforeManifest, err := capture(root, files, store, documentpkg.ManifestFilename)
if err != nil {
return result, err
}
if !beforeManifest.Exists {
return result, fmt.Errorf("no %s in this workspace; run lucy init first", documentpkg.ManifestFilename)
}
beforeLock, err := capture(root, files, store, documentpkg.LockFilename)
if err != nil {
return result, err
}
reader, err := imageReader(store, beforeManifest)
if err != nil {
return result, err
}
existingManifest, readErr := io.ReadAll(reader)
closeErr := reader.Close()
if err := errors.Join(readErr, closeErr); err != nil {
return result, err
}
document, err := documentpkg.DecodeManifest(bytes.NewReader(existingManifest))
if err != nil {
return result, err
}
own := make(map[string]struct{})
record := func(state image) {
if state.Exists && state.Hash != "" {
own[state.Hash] = struct{}{}
}
}
record(beforeManifest)
record(beforeLock)
defer func() {
reclaimed, sweepErr := reclaimObjects(root, files, store, own)
if sweepErr != nil {
s.logger.Warn(sweepErr)
}
result.Reclaimed = reclaimed
if reclaimed.Objects > 0 {
s.logger.Info(
"reclaimed unreferenced objects",
"objects", reclaimed.Objects,
"bytes", reclaimed.Bytes,
)
}
}()
if err := workspacewrite.CheckInactive(root, "changing tracked files"); err != nil {
return result, err
}
var locked *documentpkg.Lock
currentLock := false
existingLock := []byte{}
if beforeLock.Exists {
reader, err := imageReader(store, beforeLock)
if err != nil {
return result, err
}
existingLock, readErr = io.ReadAll(reader)
closeErr := reader.Close()
if err := errors.Join(readErr, closeErr); err != nil {
return result, err
}
locked, err = documentpkg.DecodeLock(bytes.NewReader(existingLock))
if err != nil {
return result, err
}
fingerprint, err := document.Fingerprint()
if err != nil {
return result, err
}
currentLock = locked.ManifestFingerprint == fingerprint
}
selected, skipped, err := selectFiles(document.Files, request)
if err != nil {
return result, err
}
result.Skipped = skipped
if len(selected) == 0 {
return result, nil
}
event := Event{Sequence: uint64(len(events) + 1), Time: time.Now().UTC(), ID: rand.Text(), Operation: request.Operation, Changes: make([]Change, 0, len(selected))}
txn := transaction{HistorySize: historySize}
nextFiles := slices.Clone(document.Files)
for _, path := range selected {
if err := ctx.Err(); err != nil {
return result, err
}
before, err := capture(root, files, store, path)
if err != nil {
return result, err
}
index := slices.IndexFunc(nextFiles, func(file documentpkg.TrackedFile) bool { return file.Path == path })
var previous *documentpkg.TrackedFile
if index >= 0 {
copy := nextFiles[index]
previous = ©
}
change, after, err := prepare(ctx, s.client, store, events, event.Sequence, request, path, previous, before)
if err != nil {
return result, err
}
event.Changes = append(event.Changes, change)
txn.Mutations = append(txn.Mutations, mutation{Path: path, Before: before, After: after})
if change.After == nil {
nextFiles = slices.Delete(nextFiles, index, index+1)
} else if index >= 0 {
nextFiles[index] = *change.After
} else {
nextFiles = append(nextFiles, *change.After)
}
}
document.Files = nextFiles
if err := document.Normalize(); err != nil {
return result, err
}
if err := ValidateOwnership(root, document.Files, locked); err != nil {
return result, err
}
raw, err := document.MergeBytes(existingManifest)
if err != nil {
return result, err
}
afterManifest, err := storeBytes(store, raw, os.FileMode(beforeManifest.Mode))
if err != nil {
return result, err
}
record(afterManifest)
txn.Mutations = append(txn.Mutations, mutation{Path: documentpkg.ManifestFilename, Before: beforeManifest, After: afterManifest})
if locked != nil {
locked.Files = slices.Clone(document.Files)
if currentLock {
locked.ManifestFingerprint, err = document.Fingerprint()
if err != nil {
return result, err
}
}
if err := locked.Validate(); err != nil {
return result, err
}
raw, err := locked.MergeBytes(existingLock)
if err != nil {
return result, err
}
afterLock, err := storeBytes(store, raw, os.FileMode(beforeLock.Mode))
if err != nil {
return result, err
}
record(afterLock)
txn.Mutations = append(txn.Mutations, mutation{Path: documentpkg.LockFilename, Before: beforeLock, After: afterLock})
}
txn.Event = event
if err := ctx.Err(); err != nil {
return result, err
}
if err := commit(root, files, store, txn); err != nil {
return result, err
}
result.Event = event
return result, nil
}
func validateRequest(request Request) error {
switch request.Operation {
case Track, Untrack, Update, Recover, Revert, Lock, Unlock:
default:
return fmt.Errorf("unknown file operation %q", request.Operation)
}
if request.All {
if len(request.Paths) > 0 || (request.Operation != Update && request.Operation != Recover) {
return fmt.Errorf("--all requires update or recover without a path")
}
} else if len(request.Paths) == 0 {
return fmt.Errorf("file operation requires a path")
}
if request.FromSource && request.Operation != Update {
return fmt.Errorf("--from-source requires update")
}
if (request.Immutable || request.NoHash || request.RecoverURL != "") && request.Operation != Track {
return fmt.Errorf("tracking choices require track")
}
if request.NoHash && (request.Immutable || request.RecoverURL != "") {
return fmt.Errorf("--no-hash conflicts with --immutable and --recover-url")
}
if (request.Hash != "" || request.Revision != 0) && request.Operation != Revert {
return fmt.Errorf("revision targets require revert")
}
if request.Hash != "" && request.Revision != 0 {
return fmt.Errorf("hash and --revision are mutually exclusive")
}
return nil
}
func selectFiles(files []documentpkg.TrackedFile, request Request) ([]string, []string, error) {
if !request.All {
selected := make([]string, 0, len(request.Paths))
for _, raw := range request.Paths {
path, err := input.TrackedPath(raw)
if err != nil {
return nil, nil, err
}
if slices.Contains(selected, path) {
continue
}
index := slices.IndexFunc(files, func(file documentpkg.TrackedFile) bool { return file.Path == path })
if request.Operation == Track && index >= 0 {
return nil, nil, fmt.Errorf("file %s is already tracked; use update", path)
}
if request.Operation != Track && index < 0 {
return nil, nil, fmt.Errorf("file %s is not tracked", path)
}
selected = append(selected, path)
}
return selected, nil, nil
}
selected, skipped := make([]string, 0, len(files)), make([]string, 0)
for _, file := range files {
if (request.FromSource && file.RecoverURL == "") || (request.Operation == Recover && file.Hash == "") {
skipped = append(skipped, file.Path)
continue
}
selected = append(selected, file.Path)
}
return selected, skipped, nil
}
func prepare(ctx context.Context, client *http.Client, store *cas.Store, events []Event, sequence uint64, request Request, path string, previous *documentpkg.TrackedFile, before image) (Change, image, error) {
change := Change{Path: path, Before: previous}
after := before
file := documentpkg.TrackedFile{Path: path}
if previous != nil {
file = *previous
change.Revision = currentRevision(events, path, file.Hash).sequence
}
if before.Exists {
change.BackupHash, change.BackupMode = before.Hash, before.Mode
}
switch request.Operation {
case Track:
if !before.Exists {
return change, after, fmt.Errorf("cannot track missing file %s", path)
}
file.Immutable, file.RecoverURL = request.Immutable, request.RecoverURL
if !request.NoHash {
file.Hash = before.Hash
}
case Untrack:
return change, after, nil
case Update:
if request.FromSource {
var err error
after, err = sourceImage(ctx, client, store, file, true, false)
if err != nil {
return change, after, err
}
if before.Exists {
after.Mode = before.Mode
}
} else if !before.Exists {
return change, after, fmt.Errorf("cannot accept missing file %s", path)
}
file.Hash = after.Hash
case Recover:
if file.Hash == "" {
return change, after, fmt.Errorf("file %s has no accepted hash to recover", path)
}
var err error
after, err = sourceImage(ctx, client, store, file, false, false)
if err != nil {
return change, after, err
}
if revision := currentRevision(events, path, file.Hash); revision.mode != 0 {
after.Mode = uint32(revision.mode.Perm())
} else if before.Exists {
after.Mode = before.Mode
}
case Revert:
if file.Hash == "" {
return change, after, fmt.Errorf("file %s has no accepted revision", path)
}
revision, err := revertRevision(events, path, file.Hash, request.Hash, request.Revision)
if err != nil {
return change, after, err
}
if err := verifyObject(store, revision.hash); err != nil {
return change, after, fmt.Errorf("historical backup unavailable for %s: %w", path, err)
}
file.Hash = revision.hash
after = image{Exists: true, Hash: revision.hash, Mode: uint32(revision.mode.Perm())}
if after.Mode == 0 {
after.Mode = 0o600
}
change.Revision = revision.sequence
case Lock:
if !before.Exists {
return change, after, fmt.Errorf("cannot lock missing file %s", path)
}
file.Immutable, file.Hash = true, before.Hash
case Unlock:
file.Immutable = false
}
change.After = &file
if (request.Operation == Track || request.Operation == Update || request.Operation == Lock) && file.Hash != "" {
change.Parent = change.Revision
change.Revision = sequence
change.Accepted = true
change.Mode = after.Mode
}
return change, after, nil
}
func verifyObject(store *cas.Store, hash string) (resultErr error) {
digest, err := cas.ParseDigest(hash)
if err != nil {
return err
}
reader, err := store.Open(digest)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, reader.Close()) }()
_, err = io.Copy(io.Discard, reader)
return err
}
// recoveryTimeout bounds one recovery download end to end, so a source that
// accepts the connection and stops responding cannot hold up an installation.
const recoveryTimeout = 5 * time.Minute
// maxRecoveryBytes bounds one recovery download. Tracked files are working
// files, so a longer stream is a misconfigured or hostile source.
const maxRecoveryBytes = 64 << 20
// cappedReader fails a stream that exceeds its byte budget. Truncating instead
// would store short content, which commits as an object when no accepted digest
// pins the contents.
type cappedReader struct {
reader io.Reader
left int64
}
func (c *cappedReader) Read(p []byte) (int, error) {
n, err := c.reader.Read(p)
c.left -= int64(n)
if c.left < 0 {
return n, fmt.Errorf("stream exceeds %d bytes", maxRecoveryBytes)
}
return n, err
}
func sourceImage(ctx context.Context, client *http.Client, store *cas.Store, file documentpkg.TrackedFile, update, offline bool) (result image, resultErr error) {
if !update {
if err := verifyObject(store, file.Hash); err == nil {
return image{Exists: true, Hash: file.Hash, Mode: 0o600}, nil
}
}
if offline || file.RecoverURL == "" {
return result, fmt.Errorf("no verified local backup or recovery source for %s", file.Path)
}
if client == nil {
return result, fmt.Errorf("recovery source for %s requires an HTTP client", file.Path)
}
ctx, cancel := context.WithTimeout(ctx, recoveryTimeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, file.RecoverURL, nil)
if err != nil {
return result, err
}
response, err := client.Do(request)
if err != nil {
return result, fmt.Errorf("fetch recovery source for %s: %w", file.Path, err)
}
defer func() { resultErr = errors.Join(resultErr, response.Body.Close()) }()
if response.StatusCode != http.StatusOK {
return result, fmt.Errorf("recovery source for %s returned HTTP %d", file.Path, response.StatusCode)
}
if response.ContentLength > maxRecoveryBytes {
return result, fmt.Errorf(
"recovery source for %s declares %d bytes, over the %d byte limit",
file.Path,
response.ContentLength,
maxRecoveryBytes,
)
}
var expected *cas.Digest
if !update {
digest, err := cas.ParseDigest(file.Hash)
if err != nil {
return result, err
}
expected = &digest
}
object, err := store.Put(
&cappedReader{reader: response.Body, left: maxRecoveryBytes},
expected,
)
if err != nil {
return result, fmt.Errorf("invalid recovery contents for %s: %w", file.Path, err)
}
return image{Exists: true, Hash: object.Digest.String(), Mode: 0o600}, nil
}
// ValidateOwnership includes generated outputs retained by the runtime receipt.
func ValidateOwnership(root string, files []documentpkg.TrackedFile, locked *documentpkg.Lock) error {
if len(files) == 0 {
return nil
}
outputs := []string{}
add := func(directory, name string) {
outputs = append(outputs, strings.ToLower(filepath.ToSlash(filepath.Join(directory, name))))
}
receiptName := ".lucy/install.yaml"
if err := workspacewrite.SafePath(root, receiptName); err != nil {
return err
}
raw, err := os.ReadFile(filepath.Join(root, receiptName))
if err == nil {
var receipt struct {
Entries []struct {
Path string `yaml:"path"`
} `yaml:"entries"`
}
if err := yaml.Unmarshal(raw, &receipt); err != nil {
return err
}
for _, entry := range receipt.Entries {
add(".", entry.Path)
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if locked != nil {
server := locked.Server
switch server.Bootstrapper {
case "", "paperclip":
add(server.Directory, "server.jar")
case "fabric":
for _, name := range []string{"run.sh", "run.bat", "fabric-server-launch.jar", "fabric-server-launcher.properties", "libraries/net/fabricmc/fabric-loader/" + string(server.CoreVersion) + "/fabric-loader-" + string(server.CoreVersion) + ".jar"} {
add(server.Directory, name)
}
case "forge-installer":
add(server.Directory, "run.sh")
add(server.Directory, "run.bat")
}
for _, input := range server.Inputs {
add(server.Directory, input.Path)
}
if locked.MCDR != nil {
add(locked.MCDR.Directory, "run-mcdr.sh")
}
for _, pkg := range locked.Packages {
if !pkg.Enabled() {
continue
}
directory, folder := server.Directory, "mods"
if pkg.Ecosystem == catalog.EcosystemBukkit || pkg.Ecosystem == catalog.EcosystemPaper {
folder = "plugins"
}
if pkg.Environment == serverpkg.EnvironmentMCDR {
if locked.MCDR == nil {
return fmt.Errorf("MCDR package has no wrapper runtime")
}
directory, folder = locked.MCDR.Directory, "plugins"
}
add(directory, filepath.Join(folder, pkg.Filename))
}
}
for _, file := range files {
path := strings.ToLower(file.Path)
for _, output := range outputs {
if path == output || strings.HasPrefix(path, output+"/") || strings.HasPrefix(output, path+"/") {
return fmt.Errorf("tracked file %s collides with managed runtime output %s", file.Path, output)
}
}
}
return nil
}
package filetrack
import (
"bufio"
"encoding/json/v2"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/mclucy/lucy/cas"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/internal/workspacewrite"
)
const (
historyName = ".lucy/history/files.jsonl"
transactionName = ".lucy/file-commit.json"
)
// History is read-only and includes events for paths that are no longer tracked.
func History(root, path string) ([]Event, error) {
if err := noPending(root); err != nil {
return nil, err
}
if path != "" {
var err error
path, err = input.TrackedPath(path)
if err != nil {
return nil, err
}
}
events, _, err := readHistory(root, false)
if err != nil {
return nil, err
}
if path == "" {
return events, nil
}
selected := make([]Event, 0)
for _, event := range events {
changes := make([]Change, 0)
for _, change := range event.Changes {
if change.Path == path {
changes = append(changes, change)
}
}
if len(changes) != 0 {
event.Changes = changes
selected = append(selected, event)
}
}
return selected, nil
}
func noPending(root string) error {
for _, name := range []string{transactionName, workspacewrite.InstallJournalName} {
if err := workspacewrite.SafePath(root, name); err != nil {
return err
}
if _, err := os.Stat(filepath.Join(root, name)); err == nil {
return fmt.Errorf("unfinished workspace transaction; run a mutating Lucy command to recover it")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}
func readHistory(root string, repair bool) (events []Event, offset int64, resultErr error) {
if err := workspacewrite.SafePath(root, historyName); err != nil {
return nil, 0, err
}
flag := os.O_RDONLY
if repair {
flag = os.O_RDWR
}
file, err := os.OpenFile(filepath.Join(root, historyName), flag, 0)
if errors.Is(err, os.ErrNotExist) {
return []Event{}, 0, nil
}
if err != nil {
return nil, 0, err
}
defer func() { resultErr = errors.Join(resultErr, file.Close()) }()
reader := bufio.NewReader(file)
events = make([]Event, 0)
identities := map[string]bool{}
for {
line, err := reader.ReadBytes('\n')
if errors.Is(err, io.EOF) {
if len(line) != 0 {
if !repair {
return nil, offset, fmt.Errorf("incomplete file history tail; run a mutating file command to recover it")
}
if err := file.Truncate(offset); err != nil {
return nil, offset, err
}
if err := file.Sync(); err != nil {
return nil, offset, err
}
}
return events, offset, nil
}
if err != nil {
return nil, offset, err
}
var event Event
if err := json.Unmarshal(line, &event); err != nil {
return nil, offset, fmt.Errorf("corrupt file history at byte %d: %w", offset, err)
}
if identities[event.ID] {
return nil, offset, fmt.Errorf("duplicate file history identity at byte %d", offset)
}
identities[event.ID] = true
if err := validateEvent(events, event); err != nil {
return nil, offset, fmt.Errorf("invalid file history at byte %d: %w", offset, err)
}
events = append(events, event)
offset += int64(len(line))
}
}
func validateEvent(previous []Event, event Event) error {
_, zone := event.Time.Zone()
if event.Sequence != uint64(len(previous)+1) || event.ID == "" || event.Time.IsZero() || zone != 0 || len(event.Changes) == 0 {
return fmt.Errorf("invalid event identity")
}
switch event.Operation {
case Track, Untrack, Update, Recover, Revert, Lock, Unlock:
default:
return fmt.Errorf("unknown operation")
}
paths := make(map[string]bool, len(event.Changes))
for _, change := range event.Changes {
path, err := input.TrackedPath(change.Path)
if err != nil || path != change.Path || paths[path] || change.Parent >= event.Sequence || change.Revision > event.Sequence || (change.Mode|change.BackupMode)&^uint32(0o777) != 0 {
return fmt.Errorf("invalid change for %s", change.Path)
}
paths[path] = true
for _, declaration := range []*document.TrackedFile{change.Before, change.After} {
if declaration == nil {
continue
}
copy := []document.TrackedFile{*declaration}
if err := document.NormalizeTrackedFiles(copy); err != nil || copy[0] != *declaration || declaration.Path != path {
return fmt.Errorf("invalid declaration for %s", path)
}
}
if change.BackupHash != "" {
if _, err := cas.ParseDigest(change.BackupHash); err != nil {
return fmt.Errorf("invalid working backup for %s", path)
}
}
if change.Accepted {
if (event.Operation != Track && event.Operation != Update && event.Operation != Lock) || change.After == nil || change.After.Hash == "" || change.Revision != event.Sequence {
return fmt.Errorf("invalid accepted revision for %s", path)
}
if change.Parent != 0 {
if _, ok := revisionAt(previous, path, change.Parent); !ok {
return fmt.Errorf("missing parent revision for %s", path)
}
}
} else if change.Revision != 0 {
revision, ok := revisionAt(previous, path, change.Revision)
if !ok || (change.After != nil && change.After.Hash != revision.hash) {
return fmt.Errorf("missing active revision for %s", path)
}
}
}
return nil
}
type acceptedRevision struct {
sequence uint64
parent uint64
hash string
mode os.FileMode
}
func revisionAt(events []Event, path string, sequence uint64) (acceptedRevision, bool) {
if sequence == 0 || sequence > uint64(len(events)) {
return acceptedRevision{}, false
}
for _, change := range events[sequence-1].Changes {
if change.Path == path && change.Accepted && change.After != nil {
return acceptedRevision{sequence: sequence, parent: change.Parent, hash: change.After.Hash, mode: os.FileMode(change.Mode)}, true
}
}
return acceptedRevision{}, false
}
func currentRevision(events []Event, path, hash string) acceptedRevision {
for i := len(events) - 1; i >= 0; i-- {
for _, change := range events[i].Changes {
if change.Path == path && change.Revision != 0 {
revision, ok := revisionAt(events, path, change.Revision)
if ok && revision.hash == hash {
return revision
}
return acceptedRevision{}
}
}
}
return acceptedRevision{}
}
func revertRevision(events []Event, path, acceptedHash, hash string, sequence uint64) (acceptedRevision, error) {
if hash != "" && sequence != 0 {
return acceptedRevision{}, fmt.Errorf("hash and --revision are mutually exclusive")
}
if sequence != 0 {
if revision, ok := revisionAt(events, path, sequence); ok {
return revision, nil
}
return acceptedRevision{}, fmt.Errorf("accepted revision %d is not available for %s", sequence, path)
}
if hash != "" {
for i := len(events) - 1; i >= 0; i-- {
if revision, ok := revisionAt(events, path, uint64(i+1)); ok && revision.hash == hash {
return revision, nil
}
}
return acceptedRevision{}, fmt.Errorf("accepted revision %s is not available for %s", hash, path)
}
current := currentRevision(events, path, acceptedHash)
if previous, ok := revisionAt(events, path, current.parent); ok {
return previous, nil
}
return acceptedRevision{}, fmt.Errorf("no previous accepted revision for %s", path)
}
package filetrack
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"github.com/mclucy/lucy/cas"
documentpkg "github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/internal/workspacewrite"
)
// DeclaredFiles prefers the manifest declarations and falls back to the lock
// when only an installed workspace remains, so read-only commands also work
// without a manifest. A present but unreadable state file is an error, never an
// empty result set.
func DeclaredFiles(root string) ([]documentpkg.TrackedFile, error) {
document, manifestErr := documentpkg.ManifestFile(root).Read()
switch {
case manifestErr == nil:
return document.Files, nil
case !errors.Is(manifestErr, os.ErrNotExist):
return nil, fmt.Errorf("read manifest: %w", manifestErr)
}
locked, lockErr := documentpkg.LockFile(root).Read()
switch {
case lockErr == nil:
return locked.Files, nil
case !errors.Is(lockErr, os.ErrNotExist):
return nil, fmt.Errorf("read lockfile: %w", lockErr)
}
return nil, fmt.Errorf(
"no %s or %s in this workspace; run lucy init to create a manifest",
documentpkg.ManifestFilename,
documentpkg.LockFilename,
)
}
// Inspect makes no state directories and never contacts a recovery source.
func Inspect(root string, declarations []documentpkg.TrackedFile) (statuses []Status, resultErr error) {
if err := noPending(root); err != nil {
return nil, err
}
declarations = slices.Clone(declarations)
if err := documentpkg.NormalizeTrackedFiles(declarations); err != nil {
return nil, err
}
files, err := os.OpenRoot(root)
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, files.Close()) }()
statuses = make([]Status, 0, len(declarations))
for _, file := range declarations {
status := Status{Path: file.Path, Immutable: file.Immutable, Hash: file.Hash, RecoverURL: file.RecoverURL}
if err := workspacewrite.SafePath(root, file.Path); err != nil {
status.State, status.Error = "error", err.Error()
statuses = append(statuses, status)
continue
}
info, err := files.Lstat(file.Path)
switch {
case errors.Is(err, os.ErrNotExist):
status.State = "missing"
case err != nil:
status.State, status.Error = "error", err.Error()
case !info.Mode().IsRegular():
status.State, status.Error = "error", "tracked path is not a regular file"
case file.Hash == "":
status.State = "presence-only"
default:
actual, err := observe(files, file.Path)
if err != nil {
status.State, status.Error = "error", err.Error()
} else {
status.ActualHash = actual.Hash
status.State = "matching"
if actual.Hash != file.Hash {
status.State = "modified"
}
}
}
statuses = append(statuses, status)
}
return statuses, nil
}
// Provision requires shared workspace ownership and preserves every existing working file.
// Availability failures are diagnostics; unsafe state and incomplete transactions are errors.
func (s *Service) Provision(ctx context.Context, root string, declarations []documentpkg.TrackedFile, offline bool) (diagnostics []Diagnostic, resultErr error) {
if err := noPending(root); err != nil {
return nil, err
}
declarations = slices.Clone(declarations)
if err := documentpkg.NormalizeTrackedFiles(declarations); err != nil {
return nil, err
}
if len(declarations) == 0 {
return []Diagnostic{}, nil
}
files, err := os.OpenRoot(root)
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, files.Close()) }()
store, err := cas.New(filepath.Join(root, ".lucy"))
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, store.Close()) }()
diagnostics = make([]Diagnostic, 0)
for _, file := range declarations {
if err := ctx.Err(); err != nil {
return diagnostics, err
}
if err := workspacewrite.SafePath(root, file.Path); err != nil {
return diagnostics, err
}
info, err := files.Lstat(file.Path)
if err == nil {
if !info.Mode().IsRegular() {
return diagnostics, fmt.Errorf("tracked path %s is not a regular file", file.Path)
}
if file.Hash == "" {
continue
}
actual, err := observe(files, file.Path)
if err != nil {
return diagnostics, err
}
if actual.Hash != file.Hash && file.Immutable {
diagnostics = append(diagnostics, Diagnostic{Path: file.Path, Message: "immutable contents differ; use lucy file update to accept them or lucy file recover to restore the accepted revision"})
}
continue
}
if !errors.Is(err, os.ErrNotExist) {
return diagnostics, err
}
if file.Hash == "" {
diagnostics = append(diagnostics, Diagnostic{Path: file.Path, Message: "file is missing and has no accepted hash; distribute its contents or track an accepted revision"})
continue
}
accepted, err := sourceImage(ctx, s.client, store, file, false, offline)
if err != nil {
diagnostics = append(diagnostics, Diagnostic{Path: file.Path, Message: err.Error() + "; distribute the accepted contents or configure a source serving that digest"})
continue
}
if _, err := files.Lstat(file.Path); !errors.Is(err, os.ErrNotExist) {
if err != nil {
return diagnostics, err
}
diagnostics = append(diagnostics, Diagnostic{Path: file.Path, Message: "file appeared during recovery and was preserved"})
continue
}
reader, err := imageReader(store, accepted)
if err != nil {
diagnostics = append(diagnostics, Diagnostic{Path: file.Path, Message: "cannot open accepted contents: " + err.Error()})
continue
}
copyErr := fsutil.WriteRoot(files, file.Path, reader, fsutil.WriteOptions{
Mode: os.FileMode(accepted.Mode),
})
if err := errors.Join(copyErr, reader.Close()); err != nil {
diagnostics = append(diagnostics, Diagnostic{Path: file.Path, Message: "cannot provision accepted contents: " + err.Error()})
}
}
return diagnostics, nil
}
package filetrack
import (
"encoding/json/v2"
"errors"
"io"
"os"
"github.com/mclucy/lucy/cas"
documentpkg "github.com/mclucy/lucy/document"
)
// Reclaimed counts content store objects that a sweep removed because no
// accepted state names them.
type Reclaimed struct {
Objects int `json:"objects,omitzero"`
Bytes int64 `json:"bytes,omitzero"`
}
// reclaimObjects deletes objects outside the reference set of history, the
// manifest, the lockfile, and any pending transaction. History never forgets a
// revision, so a digest that any event named stays revertible.
//
// own holds the objects this run stored for its own transaction images. They
// are swept like any other leftover but not counted: they are the run's working
// state, not garbage it inherited.
//
// Callers hold the workspace writer lock: no command can be between storing an
// object and recording it.
func reclaimObjects(root string, files *os.Root, store *cas.Store, own map[string]struct{}) (Reclaimed, error) {
var reclaimed Reclaimed
referenced, err := references(root, files)
if err != nil {
return reclaimed, err
}
var failures []error
walkErr := store.Walk(func(object cas.Object) error {
if _, ok := referenced[object.Digest]; ok {
return nil
}
size, err := store.Remove(object.Digest)
if err != nil {
failures = append(failures, err)
return nil
}
if _, counted := own[object.Digest.String()]; counted {
return nil
}
reclaimed.Objects++
reclaimed.Bytes += size
return nil
})
return reclaimed, errors.Join(append(failures, walkErr)...)
}
// references collects every digest that a command may open later. A malformed
// hash fails the sweep: an unknowable reference set must not authorize
// deletion.
func references(root string, files *os.Root) (map[cas.Digest]struct{}, error) {
events, _, err := readHistory(root, false)
if err != nil {
return nil, err
}
referenced := make(map[cas.Digest]struct{})
for _, event := range events {
if err := addChangeReferences(referenced, event.Changes); err != nil {
return nil, err
}
}
if err := addDocumentReferences(
files,
documentpkg.ManifestFilename,
func(reader io.Reader) ([]documentpkg.TrackedFile, error) {
document, err := documentpkg.DecodeManifest(reader)
if err != nil {
return nil, err
}
return document.Files, nil
},
referenced,
); err != nil {
return nil, err
}
if err := addDocumentReferences(
files,
documentpkg.LockFilename,
func(reader io.Reader) ([]documentpkg.TrackedFile, error) {
document, err := documentpkg.DecodeLock(reader)
if err != nil {
return nil, err
}
return document.Files, nil
},
referenced,
); err != nil {
return nil, err
}
if err := addPendingReferences(files, referenced); err != nil {
return nil, err
}
return referenced, nil
}
// addPendingReferences keeps a transaction that recovery may still finish or
// roll back from losing the objects it names.
func addPendingReferences(files *os.Root, referenced map[cas.Digest]struct{}) error {
data, err := files.ReadFile(transactionName)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var txn transaction
if err := json.Unmarshal(data, &txn); err != nil {
return err
}
for _, change := range txn.Mutations {
for _, state := range []image{change.Before, change.After} {
if err := addHash(referenced, state.Hash); err != nil {
return err
}
}
}
return addChangeReferences(referenced, txn.Event.Changes)
}
func addDocumentReferences(
files *os.Root,
name string,
decode func(io.Reader) ([]documentpkg.TrackedFile, error),
referenced map[cas.Digest]struct{},
) error {
reader, err := files.Open(name)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
declarations, decodeErr := decode(reader)
closeErr := reader.Close()
if err := errors.Join(decodeErr, closeErr); err != nil {
return err
}
for _, declaration := range declarations {
if err := addHash(referenced, declaration.Hash); err != nil {
return err
}
}
return nil
}
func addChangeReferences(referenced map[cas.Digest]struct{}, changes []Change) error {
for _, change := range changes {
for _, state := range []*documentpkg.TrackedFile{change.Before, change.After} {
if state == nil {
continue
}
if err := addHash(referenced, state.Hash); err != nil {
return err
}
}
if err := addHash(referenced, change.BackupHash); err != nil {
return err
}
}
return nil
}
func addHash(referenced map[cas.Digest]struct{}, hash string) error {
if hash == "" {
return nil
}
digest, err := cas.ParseDigest(hash)
if err != nil {
return err
}
referenced[digest] = struct{}{}
return nil
}
package filetrack
import (
"bytes"
"crypto/sha256"
"encoding/json/v2"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/mclucy/lucy/cas"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/internal/workspacewrite"
"github.com/mclucy/lucy/log"
)
type image struct {
Exists bool `json:"exists"`
Hash string `json:"hash,omitempty"`
Mode uint32 `json:"mode,omitzero"`
}
type mutation struct {
Path string `json:"path"`
Before image `json:"before"`
After image `json:"after"`
}
type transaction struct {
Event Event `json:"event"`
HistorySize int64 `json:"history_size"`
Mutations []mutation `json:"mutations"`
}
func capture(root string, files *os.Root, store *cas.Store, name string) (result image, resultErr error) {
if err := workspacewrite.SafePath(root, name); err != nil {
return result, err
}
info, err := files.Lstat(name)
if errors.Is(err, os.ErrNotExist) {
return result, nil
}
if err != nil {
return result, err
}
if !info.Mode().IsRegular() {
return result, fmt.Errorf("file %s is not a regular file", name)
}
file, err := files.Open(name)
if err != nil {
return result, err
}
defer func() { resultErr = errors.Join(resultErr, file.Close()) }()
before, err := file.Stat()
if err != nil {
return result, err
}
object, err := store.Put(file, nil)
if err != nil {
return result, err
}
after, err := file.Stat()
if err != nil {
return result, err
}
if before.Size() != after.Size() || !before.ModTime().Equal(after.ModTime()) {
return result, fmt.Errorf("file %s changed while taking its snapshot", name)
}
return image{Exists: true, Hash: object.Digest.String(), Mode: uint32(info.Mode().Perm())}, nil
}
func observe(files *os.Root, name string) (result image, resultErr error) {
info, err := files.Lstat(name)
if errors.Is(err, os.ErrNotExist) {
return result, nil
}
if err != nil {
return result, err
}
if !info.Mode().IsRegular() {
return result, fmt.Errorf("file %s is not a regular file", name)
}
file, err := files.Open(name)
if err != nil {
return result, err
}
defer func() { resultErr = errors.Join(resultErr, file.Close()) }()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return result, err
}
var digest cas.Digest
hasher.Sum(digest[:0])
return image{Exists: true, Hash: digest.String(), Mode: uint32(info.Mode().Perm())}, nil
}
func storeBytes(store *cas.Store, data []byte, mode os.FileMode) (image, error) {
object, err := store.Put(bytes.NewReader(data), nil)
return image{Exists: err == nil, Hash: object.Digest.String(), Mode: uint32(mode.Perm())}, err
}
func applyImage(files *os.Root, store *cas.Store, name string, desired, expected image) error {
check := func() error {
current, err := observe(files, name)
if err != nil {
return err
}
if current != expected {
return fmt.Errorf("file %s changed before publication; keep pending transaction and resolve the edit", name)
}
return nil
}
if err := check(); err != nil {
return err
}
if !desired.Exists {
return workspacewrite.RemoveRoot(files, name)
}
reader, err := imageReader(store, desired)
if err != nil {
return err
}
copyErr := fsutil.WriteRoot(files, name, reader, fsutil.WriteOptions{
Mode: os.FileMode(desired.Mode),
Replace: true,
Check: check,
})
return errors.Join(copyErr, reader.Close())
}
func imageReader(store *cas.Store, state image) (io.ReadCloser, error) {
if !state.Exists {
return nil, os.ErrNotExist
}
digest, err := cas.ParseDigest(state.Hash)
if err != nil {
return nil, err
}
return store.Open(digest)
}
func ensureHistory(root string, files *os.Root) error {
if err := workspacewrite.SafePath(root, historyName); err != nil {
return err
}
if _, err := files.Lstat(historyName); errors.Is(err, os.ErrNotExist) {
return fsutil.AtomicWrite(filepath.Join(root, historyName), nil, 0o600)
} else {
return err
}
}
func appendEvent(files *os.Root, event Event) (resultErr error) {
data, err := json.Marshal(event)
if err != nil {
return err
}
data = append(data, '\n')
file, err := files.OpenFile(historyName, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, file.Close()) }()
if _, err := file.Write(data); err != nil {
return err
}
return file.Sync()
}
func commit(root string, files *os.Root, store *cas.Store, txn transaction) (resultErr error) {
if err := ensureHistory(root, files); err != nil {
return err
}
for _, change := range txn.Mutations {
if err := workspacewrite.SafePath(root, change.Path); err != nil {
return err
}
current, err := observe(files, change.Path)
if err != nil {
return err
}
if current != change.Before {
return fmt.Errorf("file %s changed during operation preparation", change.Path)
}
}
data, err := json.Marshal(txn)
if err != nil {
return err
}
if err := fsutil.WriteRoot(files, transactionName, bytes.NewReader(data), fsutil.WriteOptions{
Mode: 0o600,
Replace: true,
}); err != nil {
return err
}
failed := true
defer func() {
if failed {
resultErr = errors.Join(resultErr, recoverTransaction(root, files, store))
}
}()
for _, change := range txn.Mutations {
if change.Before != change.After {
if err := applyImage(files, store, change.Path, change.After, change.Before); err != nil {
return err
}
}
}
for _, change := range txn.Mutations {
current, err := observe(files, change.Path)
if err != nil {
return err
}
if current != change.After {
return fmt.Errorf("file %s changed before history commit", change.Path)
}
}
if err := appendEvent(files, txn.Event); err != nil {
return err
}
failed = false
return workspacewrite.RemoveRoot(files, transactionName)
}
// HasPendingTransaction reports whether a file transaction has not committed.
// It must run under the shared workspace writer lock.
func HasPendingTransaction(root string) (bool, error) {
if err := workspacewrite.SafePath(root, transactionName); err != nil {
return false, err
}
_, err := os.Stat(filepath.Join(root, transactionName))
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// RecoverLocked replays the installation journal and then any pending file
// transaction. The caller must hold the shared workspace writer lock: recovery
// restores the images both journals describe, so only the lock owner can prove
// no publisher is live and no later read needs the unreplayed state. History
// validation is skipped when no file transaction is pending, so recovery never
// requires writable file history from an otherwise valid workspace.
func RecoverLocked(root string, logger *log.Logger) error {
if err := workspacewrite.RecoverInstall(root, logger); err != nil {
return err
}
pending, err := HasPendingTransaction(root)
if err != nil || !pending {
return err
}
return RecoverPending(root)
}
// RecoverPending must run under the shared workspace writer lock.
func RecoverPending(root string) (resultErr error) {
if err := workspacewrite.SafePath(root, transactionName); err != nil {
return err
}
if _, err := os.Stat(filepath.Join(root, transactionName)); errors.Is(err, os.ErrNotExist) {
_, _, err := readHistory(root, true)
return err
} else if err != nil {
return err
}
if err := workspacewrite.CheckInactive(root, "recovering a pending workspace transaction"); err != nil {
return err
}
files, err := os.OpenRoot(root)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, files.Close()) }()
store, err := cas.New(filepath.Join(root, ".lucy"))
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, store.Close()) }()
return recoverTransaction(root, files, store)
}
func recoverTransaction(root string, files *os.Root, store *cas.Store) error {
data, err := files.ReadFile(transactionName)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var txn transaction
if err := json.Unmarshal(data, &txn); err != nil {
return fmt.Errorf("decode pending file transaction: %w", err)
}
if txn.Event.ID == "" || txn.Event.Sequence == 0 || len(txn.Mutations) == 0 || txn.HistorySize < 0 {
return fmt.Errorf("invalid pending file transaction")
}
events, size, err := readHistory(root, true)
if err != nil {
return err
}
sequence := txn.Event.Sequence
if sequence > uint64(len(events)+1) {
return fmt.Errorf("pending transaction has an invalid history sequence")
}
prior := events
if sequence <= uint64(len(events)) {
prior = events[:sequence-1]
}
if err := validateEvent(prior, txn.Event); err != nil {
return fmt.Errorf("invalid pending transaction event: %w", err)
}
committed := false
for _, event := range events {
if event.ID == txn.Event.ID {
expected, err := json.Marshal(txn.Event)
if err != nil {
return err
}
actual, err := json.Marshal(event)
if err != nil || !bytes.Equal(actual, expected) {
return fmt.Errorf("pending transaction conflicts with committed history")
}
committed = true
}
}
if committed && sequence != uint64(len(events)) {
return fmt.Errorf("pending transaction does not describe the latest history event")
}
if !committed && sequence != uint64(len(events)+1) {
return fmt.Errorf("pending transaction conflicts with the history sequence")
}
if !committed && size != txn.HistorySize {
return fmt.Errorf("file history changed after an interrupted transaction")
}
for _, change := range txn.Mutations {
if change.Path != document.ManifestFilename && change.Path != document.LockFilename {
if normalized, err := input.TrackedPath(change.Path); err != nil || normalized != change.Path {
return fmt.Errorf("invalid pending file transaction path")
}
}
if err := workspacewrite.SafePath(root, change.Path); err != nil {
return err
}
for _, state := range []image{change.Before, change.After} {
if state.Exists {
if _, err := cas.ParseDigest(state.Hash); err != nil || state.Mode&^uint32(0o777) != 0 {
return fmt.Errorf("invalid pending file pre-image")
}
}
}
current, err := observe(files, change.Path)
if err != nil {
return err
}
if current != change.Before && current != change.After {
return fmt.Errorf("file %s changed after interruption; keep pending transaction and resolve the edit", change.Path)
}
}
for _, change := range txn.Mutations {
desired := change.Before
if committed {
desired = change.After
}
current, err := observe(files, change.Path)
if err != nil {
return err
}
if current != change.Before && current != change.After {
return fmt.Errorf("file %s changed during interrupted transaction recovery; keep pending transaction and resolve the edit", change.Path)
}
if current != desired {
if err := applyImage(files, store, change.Path, desired, current); err != nil {
return err
}
}
}
if committed {
file, err := files.OpenFile(historyName, os.O_RDWR, 0)
if err != nil {
return err
}
syncErr := file.Sync()
if closeErr := file.Close(); syncErr != nil || closeErr != nil {
return errors.Join(syncErr, closeErr)
}
}
return workspacewrite.RemoveRoot(files, transactionName)
}
package github
import (
"context"
"encoding/json/v2"
"fmt"
"net/http"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/log"
)
// Client fetches GitHub metadata through one configured network cache.
type Client struct {
network *cache.Client
logger *log.Logger
}
// New constructs a GitHub metadata client. network supplies the cached HTTP
// transport; logger may be nil, which records nothing.
func New(network *cache.Client, logger *log.Logger) *Client {
return &Client{network: network, logger: logger}
}
// checkGitHubMessage returns the parsed error message when data is a GitHub
// API error envelope, nil otherwise.
func checkGitHubMessage(data []byte) *GhApiMessage {
var msg *GhApiMessage
err := json.Unmarshal(data, &msg)
if err == nil && msg != nil && msg.Message != "" {
return msg
}
return nil
}
func (c *Client) GetFileFromGitHub(
ctx context.Context,
apiEndpoint string,
) (
err error,
msg *GhApiMessage,
data []byte,
) {
res, err := c.get(ctx, apiEndpoint)
if err != nil {
return err, nil, nil
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("github: request failed: status %d", res.StatusCode), nil, nil
}
data = res.Data
if msg := checkGitHubMessage(data); msg != nil {
return nil, msg, data
}
var item GhItem
err = json.Unmarshal(data, &item)
if err != nil {
return fmt.Errorf("%w: %w", ErrCannotDecode, err), nil, nil
}
res, err = c.get(ctx, item.DownloadUrl)
if err != nil {
return err, nil, nil
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("github: download failed: status %d", res.StatusCode), nil, nil
}
data = res.Data
return nil, nil, data
}
func (c *Client) GetDirectoryFromGitHub(
ctx context.Context,
apiEndpoint string,
) (
err error,
msg *GhApiMessage,
items []GhItem,
) {
res, err := c.get(ctx, apiEndpoint)
if err != nil {
return err, nil, nil
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("github: request failed: status %d", res.StatusCode), nil, nil
}
data := res.Data
if msg := checkGitHubMessage(data); msg != nil {
return nil, msg, nil
}
var decodedItems []GhItem
err = json.Unmarshal(data, &decodedItems)
if err != nil {
return fmt.Errorf("%w: %w", ErrCannotDecode, err), nil, nil
}
return nil, nil, decodedItems
}
func (c *Client) get(
ctx context.Context,
rawURL string,
) (*cache.BytesResponse, error) {
c.logger.Debug("github api: GET " + rawURL)
return c.network.CachedGetRequest(
ctx,
rawURL,
cache.BytesRequestOptions{Kind: cache.KindMetadata},
)
}
package github
import (
"net/url"
"strings"
)
// ProxiedDownloadURL prefixes a release asset URL with proxy. A proxy without
// a trailing slash still separates the asset URL from its prefix. The
// canonical URL is returned when no proxy is configured or rawURL is not an
// HTTPS URL hosted on github.com under /owner/repository/releases/download/.
func ProxiedDownloadURL(rawURL, proxy string) string {
if proxy == "" || !isReleaseAssetURL(rawURL) {
return rawURL
}
if !strings.HasSuffix(proxy, "/") {
proxy += "/"
}
return proxy + rawURL
}
func isReleaseAssetURL(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil || u.Scheme != "https" || !strings.EqualFold(u.Hostname(), "github.com") {
return false
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
return len(parts) >= 6 &&
parts[0] != "" &&
parts[1] != "" &&
parts[2] == "releases" &&
parts[3] == "download" &&
parts[4] != "" &&
parts[5] != ""
}
package input
import (
"fmt"
"path"
"path/filepath"
"strings"
)
// TrackedPath rejects paths that cannot safely name portable workspace files.
func TrackedPath(value string) (string, error) {
if value == "" || strings.ContainsAny(value, "\\:\x00") || !filepath.IsLocal(value) {
return "", fmt.Errorf("tracked path must be workspace-relative: %q", value)
}
for component := range strings.SplitSeq(value, "/") {
if component == ".." || strings.EqualFold(component, ".lucy") || strings.EqualFold(component, ".git") {
return "", fmt.Errorf("tracked path traverses reserved state: %q", value)
}
}
value = path.Clean(value)
first, _, _ := strings.Cut(value, "/")
if value == "." ||
strings.EqualFold(first, "lucy.yaml") ||
strings.EqualFold(first, "lucy-lock.yaml") {
return "", fmt.Errorf("tracked path names Lucy state or a directory: %q", value)
}
return value, nil
}
// Package input parses package request syntax.
//
// A request has the form "provider:name@version". Provider and version are
// optional. Its target ecosystem is selected separately by the caller.
package input
import (
"errors"
"fmt"
"strings"
"github.com/mclucy/lucy/catalog"
versionpkg "github.com/mclucy/lucy/version"
)
var ESyntax = errors.New("invalid syntax")
// Parse parses provider:name@version. An omitted provider lets routing choose;
// an omitted version becomes VersionAny. The ecosystem remains unspecified
// until install planning.
func Parse(s string) (catalog.VersionedRef, error) {
provider, remainder, err := parseProvider(strings.TrimSpace(s))
if err != nil {
return catalog.VersionedRef{}, err
}
name, version, err := parseNameVersion(remainder)
if err != nil {
return catalog.VersionedRef{}, err
}
return catalog.VersionedRef{
Name: name, Provider: provider,
Version: version,
}, nil
}
func ToProjectName(s string) catalog.Name {
s = strings.TrimSpace(strings.ToLower(s))
s = strings.ReplaceAll(s, "_", "-")
s = strings.ReplaceAll(s, " ", "-")
return catalog.Name(s)
}
func parseProvider(s string) (catalog.Provider, string, error) {
boundary := len(s)
if at := strings.IndexByte(s, '@'); at >= 0 {
boundary = at
}
colon := strings.IndexByte(s[:boundary], ':')
if colon < 0 {
return catalog.ProviderUnspecified, s, nil
}
provider, ok := catalog.ParseProvider(strings.ToLower(s[:colon]))
if !ok || provider == catalog.ProviderUnspecified {
return catalog.ProviderUnknown, "", fmt.Errorf("%w: unknown provider %q", ESyntax, s[:colon])
}
return provider, s[colon+1:], nil
}
func parseNameVersion(s string) (catalog.Name, versionpkg.Version, error) {
parts := strings.Split(s, "@")
switch len(parts) {
case 1:
if parts[0] == "" {
return "", "", ESyntax
}
return catalog.Name(parts[0]), versionpkg.Any, nil
case 2:
if parts[0] == "" || parts[1] == "" || versionpkg.Version(parts[1]) == versionpkg.None {
return "", "", ESyntax
}
return catalog.Name(parts[0]), versionpkg.Version(parts[1]), nil
default:
return "", "", ESyntax
}
}
package install
import (
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"maps"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/artifactstore"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/cas"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/github"
"github.com/mclucy/lucy/log"
)
type downloadStore struct {
ctx context.Context
offline bool
trace *Trace
fetch *FetchSummary
network *cache.Client
logger *log.Logger
githubProxy string
injected func(*catalog.Artifact) (artifact.MaterializedFile, error)
}
func (s *downloadStore) cacheHit() {
if s.fetch != nil {
s.fetch.Cached++
}
}
func (s *downloadStore) downloaded(bytes int64) {
if s.fetch == nil {
return
}
s.fetch.Downloaded++
s.fetch.Bytes += bytes
}
// DigestFile computes the requested digests and fails when a supplied value
// does not match. sha256 is always computed because the artifact store is
// addressed by it.
func DigestFile(filename string, algorithms map[string]string) (map[string]string, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
hashers, writers, err := digestHashers(algorithms)
if err != nil {
return nil, err
}
if _, err := io.Copy(io.MultiWriter(writers...), file); err != nil {
return nil, err
}
return digestValues(hashers, algorithms, filename)
}
func digestHashers(algorithms map[string]string) (map[string]hash.Hash, []io.Writer, error) {
hashers := map[string]hash.Hash{"sha256": sha256.New()}
for algorithm := range algorithms {
switch algorithm {
case "sha1":
hashers[algorithm] = sha1.New()
case "sha512":
hashers[algorithm] = sha512.New()
case "md5":
hashers[algorithm] = md5.New()
case "sha256":
default:
return nil, nil, fmt.Errorf("unsupported artifact digest %s", algorithm)
}
}
writers := make([]io.Writer, 0, len(hashers))
for _, hasher := range hashers {
writers = append(writers, hasher)
}
return hashers, writers, nil
}
func digestValues(hashers map[string]hash.Hash, algorithms map[string]string, filename string) (map[string]string, error) {
values := make(map[string]string, len(hashers))
for algorithm, hasher := range hashers {
values[algorithm] = hex.EncodeToString(hasher.Sum(nil))
if expected := algorithms[algorithm]; expected != "" && values[algorithm] != strings.ToLower(expected) {
return nil, fmt.Errorf("%s checksum mismatch for %s", algorithm, filename)
}
}
return values, nil
}
// materialize returns the verified store object and the digests computed while
// verifying it. The artifact is an input only; callers attach the returned
// hashes when the artifact becomes part of a lock. A store hit performs no
// network or cache access; a miss copies verified bytes from the network cache
// or the network into the store.
func (s *downloadStore) materialize(a *catalog.Artifact) (artifact.MaterializedFile, error) {
if err := s.ctx.Err(); err != nil {
return artifact.MaterializedFile{}, err
}
if a.Embedded != "" {
return artifact.MaterializedFile{}, fmt.Errorf("embedded artifacts are extracted from their pinned parent")
}
if a.Filename == "" || filepath.Base(a.Filename) != a.Filename || strings.ContainsAny(a.Filename, "/\\") {
return artifact.MaterializedFile{}, fmt.Errorf("invalid artifact filename %q", a.Filename)
}
if s.injected != nil {
return s.injectedMaterialize(a)
}
store, err := artifactstore.Open()
if err != nil {
return artifact.MaterializedFile{}, err
}
defer store.Close()
if expected := strings.ToLower(strings.TrimSpace(a.Hashes["sha256"])); expected != "" {
digest, err := cas.ParseDigest("sha256:" + expected)
if err != nil {
return artifact.MaterializedFile{}, fmt.Errorf(
"artifact %s has an invalid sha256 digest %q: %w",
a.Filename, expected, err,
)
}
destination := store.Path(digest)
hashes, err := DigestFile(destination, a.Hashes)
if err == nil {
s.cacheHit()
s.logger.Debug(
"artifact verified",
"file", a.Filename,
"sha256", hashes["sha256"],
"source", "store",
)
return artifact.MaterializedFile{Path: destination, Hashes: hashes}, nil
}
if !errors.Is(err, os.ErrNotExist) {
s.logger.Debug(
"store object is unusable; acquiring again",
"file", a.Filename,
"error", err,
)
}
}
source, cleanup, err := s.downloadArtifact(a)
if cleanup != nil {
defer cleanup()
}
if err != nil {
return artifact.MaterializedFile{}, err
}
object, hashes, err := importFile(store.Store, source, a.Filename, a.Hashes)
if err != nil {
return artifact.MaterializedFile{}, err
}
s.logger.Debug(
"artifact verified",
"file", a.Filename,
"sha256", hashes["sha256"],
)
return artifact.MaterializedFile{Path: store.Path(object.Digest), Hashes: hashes}, nil
}
// injectedMaterialize verifies bytes another operation supplied. Digests are
// recomputed from the returned file, so an injected materializer cannot lower
// the hash guarantee. Verified bytes enter the artifact store because a lock
// that mentions them must remain replayable. It does not touch the fetch
// summary, which describes the configured cache's transfers.
func (s *downloadStore) injectedMaterialize(a *catalog.Artifact) (artifact.MaterializedFile, error) {
expected := maps.Clone(a.Hashes)
input := *a
input.Hashes = maps.Clone(a.Hashes)
input.Modules = slices.Clone(a.Modules)
for i := range input.Modules {
input.Modules[i].Provides = slices.Clone(a.Modules[i].Provides)
input.Modules[i].Dependencies = slices.Clone(a.Modules[i].Dependencies)
}
materialized, err := s.injected(&input)
if err != nil {
return artifact.MaterializedFile{}, err
}
if materialized.Path == "" {
return artifact.MaterializedFile{}, fmt.Errorf("injected artifact %s has no path", a.Filename)
}
store, err := artifactstore.Open()
if err != nil {
return artifact.MaterializedFile{}, err
}
defer store.Close()
object, hashes, err := importFile(store.Store, materialized.Path, a.Filename, expected)
if err != nil {
return artifact.MaterializedFile{}, err
}
return artifact.MaterializedFile{Path: store.Path(object.Digest), Hashes: hashes}, nil
}
// importFile copies a verified local file into the store while checking every
// supplied digest. A mismatch aborts the stage, so unusable bytes are never
// published. Any previous object at the same address is replaced by these
// verified bytes.
func importFile(
store *cas.Store,
filename, display string,
expected map[string]string,
) (object cas.Object, hashes map[string]string, resultErr error) {
file, err := os.Open(filename)
if err != nil {
return cas.Object{}, nil, err
}
defer file.Close()
hashers, writers, err := digestHashers(expected)
if err != nil {
return cas.Object{}, nil, err
}
writer, err := store.NewWriter()
if err != nil {
return cas.Object{}, nil, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Abort()) }()
drains := append(writers, writer)
if _, err := io.Copy(io.MultiWriter(drains...), file); err != nil {
return cas.Object{}, nil, fmt.Errorf("ingest artifact: %w", err)
}
hashes, err = digestValues(hashers, expected, display)
if err != nil {
return cas.Object{}, nil, err
}
object, err = writer.Commit(nil)
if err != nil {
return cas.Object{}, nil, err
}
return object, hashes, nil
}
// downloadArtifact obtains candidate bytes from the network cache or, in
// offline mode, the cache alone. The returned path is temporary; callers
// import it into the store before removing the directory.
func (s *downloadStore) downloadArtifact(a *catalog.Artifact) (string, func(), error) {
directory, err := os.MkdirTemp("", "lucy-artifact-")
if err != nil {
return "", nil, err
}
cleanup := func() { os.RemoveAll(directory) }
if s.offline {
hit, cached, _, err := s.network.Get(a.URL)
if err != nil {
return "", cleanup, err
}
if !hit || cached == nil {
return "", cleanup, fmt.Errorf("offline artifact unavailable: %s", a.URL)
}
defer cached.Close()
destination := filepath.Join(directory, a.Filename)
out, err := os.Create(destination)
if err != nil {
return "", cleanup, err
}
_, copyErr := io.Copy(out, cached)
closeErr := out.Close()
cachedErr := cached.Close()
if copyErr != nil {
return "", cleanup, copyErr
}
if closeErr != nil {
return "", cleanup, closeErr
}
if cachedErr != nil {
return "", cleanup, cachedErr
}
s.cacheHit()
return destination, cleanup, nil
}
var read int64
var total int64
options := cache.DownloadOptions{
Kind: cache.KindArtifact,
Filename: a.Filename,
RequestURL: github.ProxiedDownloadURL(a.URL, s.githubProxy),
FileMode: 0o600,
}
options.WrapReader = func(reader io.Reader, size int64) io.Reader {
total = size
s.trace.fetchStarted(a.Filename, size)
return &fetchReader{
reader: reader,
name: a.Filename,
total: size,
read: &read,
store: s,
}
}
s.logger.Debug(
"artifact fetch",
"file", a.Filename,
"host", downloadHost(options.RequestURL),
)
started := time.Now()
result, err := s.network.CachedDownload(s.ctx, a.URL, directory, options)
if err != nil {
return "", cleanup, err
}
source := result.File.Name()
if err := result.File.Close(); err != nil {
return "", cleanup, err
}
origin := "network"
if result.CacheHit {
origin = "cache"
s.cacheHit()
} else {
s.downloaded(read)
s.trace.fetchProgress(a.Filename, read, total)
s.trace.fetchDone(a.Filename, false)
}
s.logger.Debug(
"artifact fetched",
"file", a.Filename,
"source", origin,
"bytes", read,
"duration", time.Since(started).Round(time.Millisecond),
)
return source, cleanup, nil
}
// downloadHost names the host a download goes to, so a slow fetch points at
// the endpoint rather than at the resolver.
func downloadHost(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return ""
}
return parsed.Host
}
// fetchReader reports download byte progress through the operation trace.
type fetchReader struct {
reader io.Reader
name string
total int64
read *int64
store *downloadStore
}
func (r *fetchReader) Read(buffer []byte) (int, error) {
count, err := r.reader.Read(buffer)
*r.read += int64(count)
r.store.trace.fetchProgress(r.name, *r.read, r.total)
return count, err
}
package install
import (
"os"
"path/filepath"
"strings"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/log"
)
// removeSupersededPaperCore deletes the adopted paperclip jar after the
// installer has materialized the canonical server.jar. Leaving both files in
// the workspace makes discovery report two identical runtimes.
func removeSupersededPaperCore(root string, d *document.Lock, logger *log.Logger) {
if d == nil || d.Server.Bootstrapper != "paperclip" {
return
}
filename := d.Server.Artifact.Filename
if filename == "" || filename == "server.jar" {
return
}
expected := strings.ToLower(d.Server.Artifact.Hashes["sha256"])
if expected == "" {
return
}
directory := d.Server.Directory
if directory == "" {
directory = "."
}
path := filepath.Join(root, directory, filename)
if _, err := os.Stat(path); err != nil {
return
}
hashes, err := DigestFile(path, map[string]string{"sha256": expected})
if err != nil {
logger.Warn(err)
return
}
if !strings.EqualFold(hashes["sha256"], expected) {
return
}
if err := os.Remove(path); err != nil {
logger.Warn(err)
return
}
logger.Debug("removed superseded Paper core", "path", path)
}
package install
import (
"context"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/javaruntime"
)
// selectJava selects the Java runtime of the locked server. When the
// selection misses the requirement and the caller can provision a runtime,
// the caller runs first and the runtime is selected again.
func (s *Service) selectJava(ctx context.Context, root string, resolved *document.Lock, opts Options) (*javaruntime.Check, error) {
requirement := javaruntime.RequirementOf(resolved, s.logger)
check, err := javaruntime.Select(ctx, root, requirement, s.logger)
if err != nil {
return nil, err
}
if opts.ProvisionJava == nil || check.Satisfied() {
return check, nil
}
home, err := opts.ProvisionJava(ctx, requirement, check)
if err != nil {
return nil, err
}
return javaruntime.Select(ctx, root, requirement, s.logger, home)
}
package install
import (
"context"
"fmt"
"slices"
"strings"
"github.com/mclucy/lucy/catalog"
documentpkg "github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/version"
)
const mcdrPyPIURL = "https://pypi.org/pypi/mcdreforged/json"
// resolveMCDRVersion picks the MCDReforged version recorded in the lock. A
// floating selector keeps the locked version; otherwise the latest stable
// release comes from PyPI metadata, without touching the local Python.
func (s *Service) resolveMCDRVersion(
ctx context.Context,
request version.Version,
previous *documentpkg.Lock,
offline bool,
) (version.Version, error) {
if !isFloating(request) {
return request, nil
}
if previous != nil && previous.MCDR != nil {
return previous.MCDR.Version, nil
}
if offline {
return "", fmt.Errorf(
"MCDReforged version %q needs network metadata; run online or pin mcdr.version",
request,
)
}
return s.latestMCDRVersion(ctx)
}
func (s *Service) latestMCDRVersion(ctx context.Context) (version.Version, error) {
var document struct {
Info struct {
Version string `json:"version"`
} `json:"info"`
}
if err := s.source.Metadata(ctx, mcdrPyPIURL, &document); err != nil {
return "", fmt.Errorf("look up MCDReforged releases: %w", err)
}
raw := strings.TrimSpace(document.Info.Version)
if raw == "" {
return "", fmt.Errorf("MCDReforged releases carry no version")
}
engine, err := version.NewEngine(version.DialectMCDR)
if err != nil {
return "", err
}
if _, err := engine.ParseVersion(version.Version(raw)); err != nil {
return "", fmt.Errorf("MCDReforged release version %q is not usable: %w", raw, err)
}
return version.Version(raw), nil
}
// mcdrPythonRequirements returns the pip requirements that the locked MCDR
// package set needs. The interpreter resolves and downloads wheels on the
// machine that runs install, so no Python version or wheel is recorded in the
// portable lock. The isolated environment is keyed by this list, so a changed
// list provisions a fresh environment.
func mcdrPythonRequirements(d *documentpkg.Lock) []string {
if d == nil || d.MCDR == nil {
return nil
}
requirements := []string{"mcdreforged==" + string(d.MCDR.Version)}
for _, p := range d.Packages {
for _, m := range p.Modules {
for _, dependency := range m.Dependencies {
if dependency.Kind != catalog.DependencyKindPython {
continue
}
id := strings.TrimSpace(dependency.ID)
if id == "" || id == "mcdreforged" {
continue
}
requirements = append(requirements, id)
}
}
}
slices.Sort(requirements)
return slices.Compact(requirements)
}
package install
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/workspace"
)
// Plan is the resolved outcome of one installation decision. It carries the
// documents the commit will publish and the fingerprints it was planned from,
// so applying it cannot silently consume a concurrent edit.
type Plan struct {
Manifest *document.Manifest
Previous *document.Lock
Resolved *document.Lock
PublishManifest bool
PublishLock bool
// BaseManifestFingerprint and BaseLockFingerprint describe the workspace
// state the plan answers. ApplyPlan rejects a workspace that no longer
// matches them.
BaseManifestFingerprint string
BaseLockFingerprint string
Result *Result
}
// checkPlannedDocuments verifies that a plan carries the documents it recorded
// as its base. Plans built from documents that journal recovery later replaced
// would otherwise publish state the recovered workspace never answered.
func checkPlannedDocuments(plan *Plan) error {
manifestFingerprint := ""
if plan.Manifest != nil {
fingerprint, err := plan.Manifest.Fingerprint()
if err != nil {
return err
}
manifestFingerprint = fingerprint
}
if !plan.PublishManifest && manifestFingerprint != plan.BaseManifestFingerprint {
return fmt.Errorf("plan manifest does not match its recorded base; replan from current intent")
}
lockFingerprint := ""
if plan.Previous != nil {
fingerprint, err := plan.Previous.Fingerprint()
if err != nil {
return err
}
lockFingerprint = fingerprint
}
if lockFingerprint != plan.BaseLockFingerprint {
return fmt.Errorf("plan lock does not match its recorded base; replan from current state")
}
return nil
}
// checkPlannedState rejects a workspace whose documents no longer match the
// fingerprints the plan recorded. An empty fingerprint means the document was
// absent.
func checkPlannedState(root string, plan *Plan) error {
manifestFingerprint, lockFingerprint, err := workspaceFingerprints(root)
if err != nil {
return err
}
if err := comparePlannedFingerprint(
"manifest",
plan.BaseManifestFingerprint,
manifestFingerprint,
"intent",
); err != nil {
return err
}
return comparePlannedFingerprint(
"lock",
plan.BaseLockFingerprint,
lockFingerprint,
"state",
)
}
// workspaceFingerprints reports the fingerprints of the manifest and lock a
// workspace currently holds. An absent document yields an empty fingerprint,
// so planning and applying share one description of the state a plan answers.
func workspaceFingerprints(root string) (manifestFingerprint, lockFingerprint string, err error) {
currentManifest, currentLock, err := workspaceDocuments(root)
if err != nil {
return "", "", err
}
if currentManifest != nil {
if manifestFingerprint, err = currentManifest.Fingerprint(); err != nil {
return "", "", err
}
}
if currentLock != nil {
if lockFingerprint, err = currentLock.Fingerprint(); err != nil {
return "", "", err
}
}
return manifestFingerprint, lockFingerprint, nil
}
// comparePlannedFingerprint reports how a workspace document drifted from the
// state the plan recorded. An empty fingerprint means the document was absent.
func comparePlannedFingerprint(name, planned, current, retry string) error {
switch {
case planned == current:
return nil
case current == "":
return fmt.Errorf("%s disappeared after planning; retry from current %s", name, retry)
case planned == "":
return fmt.Errorf("%s appeared after planning; retry from current %s", name, retry)
default:
return fmt.Errorf("%s changed after planning; retry from current %s", name, retry)
}
}
// workspaceDocuments reads the documents a workspace currently holds. An
// absent document is nil, not an error.
func workspaceDocuments(root string) (*document.Manifest, *document.Lock, error) {
currentManifest, err := document.ManifestFile(root).Read()
switch {
case errors.Is(err, os.ErrNotExist):
currentManifest = nil
case err != nil:
return nil, nil, err
}
currentLock, err := document.LockFile(root).Read()
switch {
case errors.Is(err, os.ErrNotExist):
currentLock = nil
case err != nil:
return nil, nil, err
}
return currentManifest, currentLock, nil
}
// recoveredLock prefers the caller's lock when it still describes the recovered
// lock, so a replay returns the document the caller already holds. A stale
// caller lock is replaced by the recovered one; a caller lock whose recovered
// counterpart disappeared is discarded.
func recoveredLock(caller, recovered *document.Lock) (*document.Lock, error) {
if caller == nil || recovered == nil {
return recovered, nil
}
callerFingerprint, err := caller.Fingerprint()
if err != nil {
return nil, err
}
recoveredFingerprint, err := recovered.Fingerprint()
if err != nil {
return nil, err
}
if callerFingerprint == recoveredFingerprint {
return caller, nil
}
return recovered, nil
}
// preflightLocked replays interrupted journals before anything reads the
// documents they describe, then validates the state both planning and applying
// depend on. It returns the recovered documents, which are the only safe base
// for resolution and staging. The running-server check only applies when the
// operation will mutate the runtime tree.
func preflightLocked(
root string,
d *document.Manifest,
old *document.Lock,
opts Options,
mutating bool,
logger *log.Logger,
) (*document.Manifest, *document.Lock, error) {
if d == nil && old == nil {
return nil, nil, fmt.Errorf("lucy.yaml or lucy-lock.yaml is required")
}
// A pending file transaction is rolled back unless its event reached the
// history. Reading, resolving, or publishing the manifest, lock, or tracked
// files before that replay can invalidate the pre-images recovery needs.
if err := filetrack.RecoverLocked(root, logger); err != nil {
return nil, nil, err
}
baseManifest, baseLock, err := workspaceDocuments(root)
if err != nil {
return nil, nil, err
}
if mutating {
// Recovery may restore a different runtime location than the caller
// requested. Both the recovered locations and a manifest being
// published must be stopped before staging rewrites their files.
directories := make([]string, 0, 3)
add := func(directory string) {
if !slices.Contains(directories, directory) {
directories = append(directories, directory)
}
}
if baseManifest != nil {
add(baseManifest.Server.Directory)
}
if baseLock != nil {
add(baseLock.Server.Directory)
}
if opts.PublishManifest && d != nil {
add(d.Server.Directory)
}
if baseManifest == nil && baseLock == nil {
switch {
case d != nil:
add(d.Server.Directory)
case old != nil:
add(old.Server.Directory)
}
}
for _, directory := range directories {
if workspace.RunningAt(filepath.Join(root, directory)) ||
(directory != "." && workspace.RunningAt(root)) {
return nil, nil, fmt.Errorf("stop the running Minecraft server before installation")
}
}
}
// Callers that read the documents before the writer lock pin what they
// read, so recovery or a concurrent edit fails instead of being overwritten.
if opts.ExpectedManifest != "" {
fingerprint := ""
if baseManifest != nil {
fingerprint, err = baseManifest.Fingerprint()
if err != nil {
return nil, nil, err
}
}
if fingerprint != opts.ExpectedManifest {
return nil, nil, fmt.Errorf("manifest changed during package selection; retry from current intent")
}
}
if opts.ExpectedLock != "" {
fingerprint := ""
if baseLock != nil {
fingerprint, err = baseLock.Fingerprint()
if err != nil {
return nil, nil, err
}
}
if fingerprint != opts.ExpectedLock {
return nil, nil, fmt.Errorf("lock changed during package selection; retry from current state")
}
}
return baseManifest, baseLock, nil
}
// resolveLock performs the installation mode decision and resolution. The
// caller must hold the workspace writer lock.
func (s *Service) resolveLock(
ctx context.Context,
root string,
d *document.Manifest,
old *document.Lock,
opts *Options,
store *downloadStore,
result *Result,
) (*document.Lock, error) {
resolved := old
fingerprint := ""
if d != nil {
var err error
fingerprint, err = d.Fingerprint()
if err != nil {
return nil, err
}
}
resolveNeeded := false
switch {
case opts.ForceResolve:
result.Mode = ModeResolve
result.Reason = "forced resolution"
resolveNeeded = true
case old == nil:
result.Mode = ModeResolve
result.Reason = "no lock file to replay"
resolveNeeded = true
case opts.WithOptional:
result.Mode = ModeResolve
result.Reason = "optional packages requested"
resolveNeeded = true
case d != nil && old.ManifestFingerprint != fingerprint:
result.Mode = ModeResolve
result.Reason = "manifest changed since lock"
resolveNeeded = true
default:
result.Mode = ModeReplay
result.Reason = "manifest unchanged; lock replays exactly"
}
s.logger.Debug(
"installation decision",
"mode", string(result.Mode),
"reason", result.Reason,
)
if resolveNeeded {
if opts.Locked {
if opts.ForceResolve {
return nil, fmt.Errorf("locked installation cannot force resolution")
}
if !opts.RequirementChange || old == nil || d == nil {
return nil, fmt.Errorf("locked installation requires a current lucy-lock.yaml")
}
reusable, err := s.coreReusable(ctx, d, old, opts.Offline)
if err != nil {
return nil, err
}
if !reusable {
return nil, fmt.Errorf("manifest core no longer matches lucy-lock.yaml; install without --locked")
}
s.logger.Debug(
"locked installation reuses core",
"reason", "manifest core still matches lock; requirements rebind in place",
)
resolved, err = mutateLock(d, old, fingerprint)
if err != nil {
return nil, err
}
if err := resolved.Validate(); err != nil {
return nil, err
}
result.Mode = ModeLocked
result.Reason = "manifest core matches; requirements rebind in place"
opts.publishLock = true
result.applyChanges(old, resolved, d)
return resolved, nil
}
var err error
resolved, err = s.resolveInstall(ctx, root, d, old, fingerprint, opts, store, result)
if err != nil {
return nil, err
}
}
if err := resolved.Validate(); err != nil {
return nil, err
}
opts.publishLock = resolved != old
result.applyChanges(old, resolved, d)
return resolved, nil
}
func (s *Service) newDownloadStore(
ctx context.Context,
opts Options,
result *Result,
) *downloadStore {
return &downloadStore{
ctx: ctx,
offline: opts.Offline,
trace: opts.Trace,
fetch: &result.Fetch,
network: s.network,
logger: s.logger,
githubProxy: s.githubProxy,
injected: opts.Materialize,
}
}
// PlanFromResolution builds a plan from a lock another resolver produced. The
// caller must set the final manifest and the lock's manifest fingerprint, so a
// plan can never publish a lock that answers a different intent.
func PlanFromResolution(
d *document.Manifest,
previous *document.Lock,
resolved *document.Lock,
baseManifestFingerprint string,
publishManifest bool,
) (*Plan, error) {
if d == nil || resolved == nil {
return nil, fmt.Errorf("manifest and resolved lock are required")
}
fingerprint, err := d.Fingerprint()
if err != nil {
return nil, err
}
if resolved.ManifestFingerprint != fingerprint {
return nil, fmt.Errorf("resolved lock answers a different manifest")
}
if err := resolved.Validate(); err != nil {
return nil, err
}
baseLock := ""
if previous != nil {
baseLock, err = previous.Fingerprint()
if err != nil {
return nil, err
}
}
result := newResult()
result.Kind = ResultKindUpgrade
result.Mode = ModeResolve
result.Reason = "package upgrade"
result.applyChanges(previous, resolved, d)
result.PublishedManifest = publishManifest
result.PublishedLock = previous == nil || resolved != previous
return &Plan{
Manifest: d,
Previous: previous,
Resolved: resolved,
PublishManifest: publishManifest,
PublishLock: result.PublishedLock,
BaseManifestFingerprint: baseManifestFingerprint,
BaseLockFingerprint: baseLock,
Result: result,
}, nil
}
package install
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/internal/workspacewrite"
"github.com/mclucy/lucy/log"
"gopkg.in/yaml.v3"
)
// PythonHost is the process boundary for MCDReforged's Python provisioning. A
// command boundary constructs one per invocation, so interpreter discovery,
// the environment layout, and the warnings seen by every stage agree.
// LookPath, Output, and Run are its process boundaries.
type PythonHost struct {
// LookPath resolves an executable name on PATH.
LookPath func(name string) (string, error)
// Output captures stdout separately from startup diagnostics on stderr.
Output func(ctx context.Context, name string, args []string) ([]byte, error)
// Run runs one command and returns its combined output. A non-nil output
// also receives the output as it arrives.
Run func(
ctx context.Context,
name string,
args []string,
output io.Writer,
) ([]byte, error)
// Getenv reads an environment variable. LUCY_PYTHON names the preferred
// interpreter.
Getenv func(string) string
// GOOS and GOARCH name the platform whose environment layout the host
// creates.
GOOS string
GOARCH string
// Logger records non-fatal provisioning warnings. A nil logger records
// nothing.
Logger *log.Logger
directory string
interpreterMu sync.Mutex
interpreterPath string
interpreterVer string
interpreterOK bool
}
// NewPythonHost returns the host boundary for one invocation. It probes no
// interpreter until MCDReforged provisioning needs one.
func NewPythonHost(directory string, logger *log.Logger) *PythonHost {
if absolute, err := filepath.Abs(directory); err == nil {
directory = absolute
}
return &PythonHost{
directory: directory,
LookPath: exec.LookPath,
Output: func(ctx context.Context, name string, args []string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = directory
return cmd.Output()
},
Run: func(
ctx context.Context,
name string,
args []string,
output io.Writer,
) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = directory
return runProcess(cmd, output)
},
Getenv: os.Getenv,
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
Logger: logger,
}
}
// Interpreter returns the Python 3.8+ executable and its version, probing at
// most once per host.
func (h *PythonHost) Interpreter(ctx context.Context) (string, string, error) {
h.interpreterMu.Lock()
defer h.interpreterMu.Unlock()
if h.interpreterOK {
return h.interpreterPath, h.interpreterVer, nil
}
path, version, err := h.discoverInterpreter(ctx)
if err != nil {
return "", "", err
}
h.interpreterPath, h.interpreterVer = path, version
h.interpreterOK = true
return path, version, nil
}
func (h *PythonHost) discoverInterpreter(
ctx context.Context,
) (string, string, error) {
name := strings.TrimSpace(h.Getenv("LUCY_PYTHON"))
if name == "" {
name = "python3"
} else if !filepath.IsAbs(name) && (strings.ContainsRune(name, filepath.Separator) ||
(h.GOOS == "windows" && strings.ContainsRune(name, '/'))) {
name = filepath.Join(h.directory, name)
}
path, err := h.LookPath(name)
if err != nil {
return "", "", fmt.Errorf("Python interpreter %q was not found", name)
}
out, err := h.Output(
ctx,
path,
[]string{
"-c",
"import sys;print('%d.%d.%d' % sys.version_info[:3])",
},
)
if err != nil {
return "", "", fmt.Errorf(
"Python interpreter %s could not run: %w",
path,
err,
)
}
value := strings.TrimSpace(string(out))
parts := strings.Split(value, ".")
if len(parts) < 2 {
return "", "", fmt.Errorf(
"Python interpreter %s reported %q",
path,
value,
)
}
major, _ := strconv.Atoi(parts[0])
minor, _ := strconv.Atoi(parts[1])
if major < 3 || (major == 3 && minor < 8) {
return "", "", fmt.Errorf(
"Python interpreter %s is %s; MCDReforged needs 3.8 or newer",
path,
value,
)
}
if major > 3 || minor > 13 {
h.Logger.Warn(fmt.Errorf(
"MCDReforged is tested through Python 3.13; %s is Python %s and "+
"some dependencies may not publish wheels for it",
path,
value,
))
}
return path, value, nil
}
type mcdrRuntime struct {
// venv reports whether the launcher uses the isolated environment.
venv bool
// key names the isolated environment. It is empty for a global runtime.
key string
}
func mcdrFlagHint() string {
return "pass --mcdr-venv to create an isolated environment"
}
func mcdrGlobalError(missing string) error {
return fmt.Errorf(
"MCDReforged is required for this workspace, but %s was not found "+
"on PATH. Install MCDReforged and Python 3.8+ globally, or %s",
missing,
mcdrFlagHint(),
)
}
// mcdrProvisionError explains how to provide a Python environment when
// provisioning fails.
func mcdrProvisionError(cause error) error {
return fmt.Errorf(
"%w\n"+
"MCDReforged needs Python 3.8+ or uv to create an isolated "+
"environment. Install Python 3 or uv, then rerun "+
"`lucy install --mcdr-venv`",
cause,
)
}
func mcdrEnvironmentKey(
goos string,
goarch string,
mcdrVersion string,
requirements []string,
) string {
hash := sha256.New()
for _, part := range append(
[]string{mcdrVersion, goos, goarch, "venv"},
requirements...,
) {
io.WriteString(hash, part)
hash.Write([]byte{0})
}
return hex.EncodeToString(hash.Sum(nil))[:32]
}
// provisionMCDR selects the runtime that will launch MCDReforged. The default
// requires a global executable; --mcdr-venv creates an isolated environment.
func provisionMCDR(
ctx context.Context,
root string,
d *document.Lock,
store *downloadStore,
python *PythonHost,
useVenv bool,
) (mcdrRuntime, error) {
m := d.MCDR
if m == nil {
return mcdrRuntime{}, nil
}
if !useVenv {
path, err := python.LookPath("mcdreforged")
if err != nil {
return mcdrRuntime{}, mcdrGlobalError(`"mcdreforged"`)
}
if _, _, err := python.Interpreter(ctx); err != nil {
return mcdrRuntime{}, fmt.Errorf(
"MCDReforged requires Python 3.8+ on PATH: %w. "+
"Install Python 3, or %s",
err,
mcdrFlagHint(),
)
}
output, err := python.Run(ctx, path, []string{"--version"}, nil)
if err != nil {
return mcdrRuntime{}, fmt.Errorf(
"global MCDReforged at %s could not run: %w\n%s\n"+
"Repair it, or %s",
path,
err,
output,
mcdrFlagHint(),
)
}
return mcdrRuntime{}, nil
}
requirements := mcdrPythonRequirements(d)
key := mcdrEnvironmentKey(
python.GOOS,
python.GOARCH,
string(m.Version),
requirements,
)
relative := filepath.Join(m.Directory, ".python", key)
if err := workspacewrite.SafePath(root, relative); err != nil {
return mcdrRuntime{}, err
}
environment := filepath.Join(root, relative)
complete := filepath.Join(environment, ".complete")
if _, err := os.Stat(complete); err == nil {
return mcdrRuntime{venv: true, key: key}, nil
} else if !os.IsNotExist(err) {
return mcdrRuntime{}, err
}
if store.offline {
return mcdrRuntime{}, fmt.Errorf(
"MCDReforged isolated environment is not provisioned; run " +
"`lucy install --mcdr-venv` once with network access",
)
}
if err := os.RemoveAll(environment); err != nil {
return mcdrRuntime{}, err
}
if err := os.MkdirAll(filepath.Dir(environment), 0o755); err != nil {
return mcdrRuntime{}, err
}
backend, err := createMCDREnvironment(ctx, environment, store, python)
if err != nil {
return mcdrRuntime{}, mcdrProvisionError(err)
}
requirementsPath := filepath.Join(environment, "requirements.txt")
if err := os.WriteFile(
requirementsPath,
[]byte(strings.Join(requirements, "\n")+"\n"),
0o600,
); err != nil {
return mcdrRuntime{}, err
}
if err := installMCDRRequirements(
ctx,
environment,
backend,
requirementsPath,
store,
python,
); err != nil {
os.RemoveAll(environment)
return mcdrRuntime{}, mcdrProvisionError(err)
}
if err := os.WriteFile(complete, nil, 0o600); err != nil {
return mcdrRuntime{}, err
}
return mcdrRuntime{venv: true, key: key}, nil
}
func createMCDREnvironment(
ctx context.Context,
environment string,
store *downloadStore,
python *PythonHost,
) (string, error) {
var uvError error
if uv, err := python.LookPath("uv"); err == nil {
captured, err := python.Run(
ctx,
uv,
[]string{"venv", "--seed", environment},
store.trace.processOutput(),
)
if err == nil {
return "uv", nil
}
uvError = fmt.Errorf("uv venv: %w\n%s", err, captured)
python.Logger.Warn(uvError)
}
interpreter, _, err := python.Interpreter(ctx)
if err != nil {
if uvError != nil {
return "", fmt.Errorf(
"no usable Python or uv: %w; %w",
err,
uvError,
)
}
return "", err
}
captured, err := python.Run(
ctx,
interpreter,
[]string{"-m", "venv", environment},
store.trace.processOutput(),
)
if err != nil {
return "", fmt.Errorf(
"create Python environment with %s: %w\n%s",
interpreter,
err,
captured,
)
}
return "python", nil
}
func installMCDRRequirements(
ctx context.Context,
environment string,
backend string,
requirementsPath string,
store *downloadStore,
python *PythonHost,
) error {
environmentPython := filepath.Join(environment, "bin", "python")
if python.GOOS == "windows" {
environmentPython = filepath.Join(environment, "Scripts", "python.exe")
}
var commandName string
var arguments []string
if backend == "uv" {
uv, err := python.LookPath("uv")
if err != nil {
return err
}
commandName = uv
arguments = []string{
"pip",
"install",
"--python",
environmentPython,
"--requirement",
requirementsPath,
}
} else {
commandName = environmentPython
arguments = []string{
"-m",
"pip",
"install",
"--requirement",
requirementsPath,
}
}
captured, err := python.Run(
ctx,
commandName,
arguments,
store.trace.processOutput(),
)
if err != nil {
return fmt.Errorf(
"install Python requirements with %s: %w\n%s",
backend,
err,
captured,
)
}
return nil
}
// preparePython selects the MCDReforged runtime and writes its wrapper into
// the stage. It runs while the installation is staged, before commit, so a
// failed Python provisioning leaves the workspace untouched.
func preparePython(
ctx context.Context,
root string,
stage string,
d *document.Lock,
store *downloadStore,
python *PythonHost,
useVenv bool,
) error {
runtime, err := provisionMCDR(ctx, root, d, store, python, useVenv)
if err != nil {
return err
}
if d.MCDR == nil {
return nil
}
return writeMCDRWrapper(stage, d, runtime, true, python.GOOS)
}
// ensureMCDRRuntime repairs the MCDReforged launcher when the rest of the
// installation is already current. The generated config is preserved unless
// it is missing.
func ensureMCDRRuntime(
ctx context.Context,
root string,
d *document.Lock,
store *downloadStore,
python *PythonHost,
useVenv bool,
) error {
runtime, err := provisionMCDR(ctx, root, d, store, python, useVenv)
if err != nil {
return err
}
if d.MCDR == nil {
return nil
}
return writeMCDRWrapper(root, d, runtime, false, python.GOOS)
}
func writeMCDRWrapper(
base string,
d *document.Lock,
mcdr mcdrRuntime,
replaceConfig bool,
goos string,
) error {
m := d.MCDR
wrapper := filepath.Join(base, m.Directory)
if err := os.MkdirAll(wrapper, 0o755); err != nil {
return err
}
configPath := filepath.Join(wrapper, "config.yml")
_, configErr := os.Stat(configPath)
if replaceConfig || os.IsNotExist(configErr) {
entry := "java -jar server.jar nogui"
if d.Server.Bootstrapper == "fabric" || d.Server.Bootstrapper == "forge-installer" {
entry = "sh run.sh"
}
relativeServer, err := filepath.Rel(m.Directory, d.Server.Directory)
if err != nil {
return err
}
// MCDReforged parses the server log stream through a handler that
// matches the launcher, so a Bukkit-family server needs its own parser
// to report start-up and player events at all.
launchHandler := "vanilla_handler"
switch d.Server.Bootstrapper {
case "forge-installer":
launchHandler = "forge_handler"
case "paperclip":
launchHandler = "bukkit_handler"
}
config := map[string]any{
"working_directory": filepath.ToSlash(relativeServer),
"start_command": entry,
"handler": launchHandler,
}
data, err := yaml.Marshal(config)
if err != nil {
return err
}
if err := os.WriteFile(configPath, data, 0o644); err != nil {
return err
}
} else if configErr != nil {
return configErr
}
launcher := "mcdreforged"
if mcdr.venv {
pythonPath := filepath.Join(".python", mcdr.key, "bin", "python")
if goos == "windows" {
pythonPath = filepath.Join(".python", mcdr.key, "Scripts", "python.exe")
}
launcher = "'" + filepath.ToSlash(pythonPath) + "' -m mcdreforged"
}
script := "#!/bin/sh\ncd -- \"$(dirname -- \"$0\")\"\nexec " +
launcher + " \"$@\"\n"
return os.WriteFile(
filepath.Join(wrapper, "run-mcdr.sh"),
[]byte(script),
0o755,
)
}
// runProcess streams a subprocess's combined output to output while retaining
// it for the error message.
func runProcess(cmd *exec.Cmd, output io.Writer) ([]byte, error) {
if output == nil {
output = io.Discard
}
var captured bytes.Buffer
writer := io.MultiWriter(output, &captured)
cmd.Stdout = writer
cmd.Stderr = writer
err := cmd.Run()
return captured.Bytes(), err
}
package install
import (
"cmp"
"fmt"
"maps"
"slices"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
serverpkg "github.com/mclucy/lucy/server"
)
// requirementKey names one manifest declaration. A reference alone does not
// identify a declaration because the same project can be declared for two
// ecosystems or environments.
type requirementKey struct {
environment serverpkg.Environment
ecosystem catalog.Ecosystem
reference string
}
func compareRequirementKeys(a, b requirementKey) int {
return cmp.Or(
cmp.Compare(a.environment, b.environment),
cmp.Compare(a.ecosystem, b.ecosystem),
cmp.Compare(a.reference, b.reference),
)
}
// declaredRequirements collects every requirement the manifest declares for a
// package, keyed by the location and reference that name it.
func declaredRequirements(d *document.Manifest) map[requirementKey]document.Requirement {
declared := map[requirementKey]document.Requirement{}
var loaders []catalog.Ecosystem
for loader := range d.Server.Packages {
loaders = append(loaders, loader)
}
slices.Sort(loaders)
for _, loader := range loaders {
var refs []string
for ref := range d.Server.Packages[loader] {
refs = append(refs, ref)
}
slices.Sort(refs)
for _, ref := range refs {
key := requirementKey{serverpkg.EnvironmentServer, loader, ref}
declared[key] = d.Server.Packages[loader][ref]
}
}
if d.MCDR != nil {
refs := slices.Sorted(maps.Keys(d.MCDR.Packages))
for _, ref := range refs {
key := requirementKey{serverpkg.EnvironmentMCDR, catalog.EcosystemMCDR, ref}
declared[key] = d.MCDR.Packages[ref]
}
}
return declared
}
// lockPackageFor reports whether a reference names one locked package.
func lockPackageFor(ref string, p document.Package) bool {
provider, name, err := document.ParseReference(ref)
if err != nil {
return false
}
if p.Provider != provider {
return false
}
if p.ProjectID == name {
return true
}
for _, module := range p.Modules {
if module.ID == name || slices.Contains(module.Provides, name) {
return true
}
}
return false
}
// bindRequirement records the manifest's current state of one requirement on
// the package that satisfies it. A requirement the lock has never seen is
// added, so a package resolved only as a dependency becomes declared.
func bindRequirement(p *document.Package, ref string, requirement document.Requirement) {
binding := document.RequirementBinding{
Reference: ref,
Selector: requirement.Selector,
FilePattern: requirement.FilePattern,
Enabled: requirement.IsEnabled(),
}
for i := range p.Requirements {
if p.Requirements[i].Reference != ref {
continue
}
p.Requirements[i] = binding
return
}
if binding.Enabled || len(p.DependencyOf) > 0 {
p.Requirements = append(p.Requirements, binding)
}
}
// rebindRequirements reapplies the manifest's requirements onto a lock that
// already resolved them. A locked mutation never reaches upstream, so it can
// only enable or disable what the lock already pins; a declared requirement the
// lock does not satisfy is reported rather than resolved. Packages left bound
// only to requirements the manifest no longer declares are dropped, so a locked
// removal uninstalls the package instead of leaving the lock pinning it.
func rebindRequirements(d *document.Manifest, resolved *document.Lock) error {
declared := declaredRequirements(d)
keys := make([]requirementKey, 0, len(declared))
for key := range declared {
keys = append(keys, key)
}
slices.SortFunc(keys, compareRequirementKeys)
for _, key := range keys {
requirement := declared[key]
pinned := false
for i := range resolved.Packages {
p := &resolved.Packages[i]
if p.Environment != key.environment || p.Ecosystem != key.ecosystem {
continue
}
if !lockPackageFor(key.reference, *p) {
continue
}
pinned = true
bindRequirement(p, key.reference, requirement)
}
if !pinned && requirement.IsEnabled() {
return fmt.Errorf(
"lucy-lock.yaml does not pin %s; install it without --locked first",
key.reference,
)
}
}
resolved.Packages = retainDeclared(resolved.Packages, declared)
return nil
}
// retainDeclared keeps pins reachable from remaining declarations or ambient
// packages. Disabled declarations retain pins without activating their dependencies.
func retainDeclared(
packages []document.Package,
declared map[requirementKey]document.Requirement,
) []document.Package {
type identity struct {
environment string
ecosystem catalog.Ecosystem
reference catalog.Ref
}
identityOf := func(p *document.Package) identity {
return identity{
string(p.Environment),
p.Ecosystem,
catalog.Ref{Provider: p.Provider, Name: catalog.Name(p.ProjectID)},
}
}
indices := make(map[identity]int, len(packages))
retained := make([]bool, len(packages))
enabled := make([]bool, len(packages))
for i := range packages {
p := &packages[i]
indices[identityOf(p)] = i
ambient := len(p.Requirements) == 0 && len(p.DependencyOf) == 0
retained[i], enabled[i] = ambient, ambient
bindings := p.Requirements[:0]
for _, binding := range p.Requirements {
key := requirementKey{p.Environment, p.Ecosystem, binding.Reference}
if _, ok := declared[key]; ok {
bindings = append(bindings, binding)
retained[i] = true
enabled[i] = enabled[i] || binding.Enabled
}
}
p.Requirements = bindings
}
children := make([][]int, len(packages))
for i := range packages {
p := &packages[i]
key := identityOf(p)
for _, source := range p.DependencyOf {
key.reference = source
if parent, ok := indices[key]; ok {
children[parent] = append(children[parent], i)
}
}
}
queue := make([]int, 0, len(packages))
propagate := func(reached []bool) {
queue = queue[:0]
for i, yes := range reached {
if yes {
queue = append(queue, i)
}
}
for next := 0; next < len(queue); next++ {
for _, child := range children[queue[next]] {
if !reached[child] {
reached[child] = true
queue = append(queue, child)
}
}
}
}
propagate(retained)
propagate(enabled)
out := packages[:0]
for i, p := range packages {
if !retained[i] {
continue
}
p.Dependency = false
sources := p.DependencyOf[:0]
key := identityOf(&p)
for _, source := range p.DependencyOf {
key.reference = source
if parent, ok := indices[key]; ok && retained[parent] {
sources = append(sources, source)
p.Dependency = p.Dependency || enabled[parent]
}
}
p.DependencyOf = sources
out = append(out, p)
}
return out
}
// mutateLock produces the lock a locked mutation commits. Only requirements,
// the Java override, and the manifest fingerprint move; every artifact pin is
// copied as recorded.
func mutateLock(d *document.Manifest, old *document.Lock, fingerprint string) (*document.Lock, error) {
resolved := *old
resolved.ManifestFingerprint = fingerprint
resolved.Files = slices.Clone(d.Files)
resolved.Server.Java = document.NewLockedJava(old.Server.Java.Advisory, d.Server.Java)
resolved.Packages = make([]document.Package, len(old.Packages))
for i, p := range old.Packages {
resolved.Packages[i] = p
resolved.Packages[i].Requirements = slices.Clone(p.Requirements)
resolved.Packages[i].DependencyOf = slices.Clone(p.DependencyOf)
}
if err := rebindRequirements(d, &resolved); err != nil {
return nil, err
}
return &resolved, nil
}
package install
import (
"context"
"fmt"
"slices"
"time"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/resolve"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/upstream/routing"
)
// resolveInstall resolves d into a new lock document. The caller decides when
// a lock may be reused, so this function always resolves. The artifact store
// receives every artifact whose hash the lock records.
func (s *Service) resolveInstall(
ctx context.Context,
root string,
d *document.Manifest,
old *document.Lock,
fingerprint string,
opts *Options,
store *downloadStore,
result *Result,
) (*document.Lock, error) {
if d == nil {
return nil, fmt.Errorf("manifest required to resolve a lock")
}
if opts.Offline {
return nil, fmt.Errorf("offline resolution requires a current lock; metadata selection is unavailable")
}
s.logger.Info(
"resolving installation",
"minecraft", d.Server.Minecraft,
"product", d.Server.Core.Product,
"core", d.Server.Core.ProductVersion,
"mcdr", d.MCDR != nil,
)
coreReq := routing.ServerProductRequest{
Minecraft: d.Server.Minecraft,
Product: d.Server.Core.Product,
ProductVersion: d.Server.Core.ProductVersion,
Repository: d.Server.Core.Repository,
FilePattern: d.Server.Core.FilePattern,
}
var core routing.ServerProductResolution
canReuse := false
if old != nil {
reusable, err := s.coreReusable(ctx, d, old, opts.Offline)
if err != nil {
return nil, err
}
canReuse = reusable
}
if canReuse {
s.logger.Debug(
"reusing locked core resolution",
"reason", "manifest core still matches lock",
"minecraft", old.Server.Minecraft,
"product", old.Server.Product,
"core-version", old.Server.CoreVersion,
)
core = routing.ServerProductResolution{
Minecraft: old.Server.Minecraft,
Product: old.Server.Product,
ProductVersion: old.Server.CoreVersion,
Artifact: old.Server.Artifact,
Bootstrapper: old.Server.Bootstrapper,
Inputs: old.Server.Inputs,
JavaAdvisory: old.Server.Java.Advisory,
}
} else {
opts.Trace.phaseStarted("server")
start := time.Now()
var err error
core, err = s.source.ResolveServerProduct(ctx, coreReq, store.materialize)
if err != nil {
return nil, err
}
elapsed := time.Since(start)
result.addPhase("server", elapsed)
opts.Trace.phaseDone(PhaseInfo{
Name: "server",
Elapsed: elapsed,
Product: core.Product,
Version: core.ProductVersion,
Minecraft: core.Minecraft,
})
}
resolved := &document.Lock{
SchemaVersion: document.LockSchemaVersion,
ManifestFingerprint: fingerprint,
Server: document.LockedServer{
Minecraft: core.Minecraft,
Directory: d.Server.Directory,
Product: core.Product,
CoreVersion: core.ProductVersion,
Artifact: core.Artifact,
Bootstrapper: core.Bootstrapper,
Inputs: core.Inputs,
Java: document.NewLockedJava(core.JavaAdvisory, d.Server.Java),
},
Packages: []document.Package{},
Files: slices.Clone(d.Files),
}
mcdrVersion := ""
if d.MCDR != nil {
request := d.MCDR.Version
if old != nil && old.MCDR != nil && isFloating(request) {
request = old.MCDR.Version
}
resolvedVersion, err := s.resolveMCDRVersion(ctx, request, old, opts.Offline)
if err != nil {
return nil, err
}
resolved.MCDR = &document.LockedMCDR{
Directory: d.MCDR.Directory,
Version: resolvedVersion,
}
mcdrVersion = string(resolvedVersion)
}
opts.Trace.phaseStarted("packages")
start := time.Now()
coreModules, moduleErr := routing.CoreModules(
ctx,
resolved.Server.Product,
resolved.Server.Artifact,
store.materialize,
)
if moduleErr != nil {
s.logger.Warn(fmt.Errorf("read server core modules: %w", moduleErr))
} else {
resolved.Server.Artifact.Modules = coreModules
}
var previousPackages []document.Package
if old != nil {
previousPackages = old.Packages
}
packageRequest := resolve.PackagesRequest{
Context: ctx,
Manifest: d,
Previous: previousPackages,
Target: resolve.Target{
Product: resolved.Server.Product,
Bootstrapper: resolved.Server.Bootstrapper,
Minecraft: resolved.Server.Minecraft,
CoreVersion: resolved.Server.CoreVersion,
MCDRVersion: mcdrVersion,
CoreModules: resolved.Server.Artifact.Modules,
},
Materialize: store.materialize,
WithOptional: opts.WithOptional,
AcceptBroken: opts.AcceptBroken,
ExpandDependencies: !opts.declaredOnly,
Logger: s.logger,
Trace: opts.Trace.resolveTrace(),
}
if s.source != nil {
packageRequest.Candidates = s.source.Candidates
}
packages, moduleWarnings, err := resolve.Packages(packageRequest)
if err != nil {
return nil, err
}
elapsed := time.Since(start)
result.addPhase("packages", elapsed)
opts.Trace.phaseDone(PhaseInfo{
Name: "packages",
Elapsed: elapsed,
Count: len(packages),
Total: countDeclared(d),
})
result.Warnings = append(result.Warnings, moduleWarnings...)
resolved.Packages = packages
if opts.WithOptional {
for _, p := range packages {
for _, request := range p.Requirements {
if p.Environment == server.EnvironmentMCDR {
d.MCDR.Packages[request.Reference] = document.Requirement{
Selector: request.Selector,
FilePattern: request.FilePattern,
Enabled: new(request.Enabled),
}
} else {
if d.Server.Packages[p.Ecosystem] == nil {
d.Server.Packages[p.Ecosystem] = map[string]document.Requirement{}
}
d.Server.Packages[p.Ecosystem][request.Reference] = document.Requirement{
Selector: request.Selector,
FilePattern: request.FilePattern,
Enabled: new(request.Enabled),
}
}
}
}
resolved.ManifestFingerprint, err = d.Fingerprint()
if err != nil {
return nil, err
}
opts.PublishManifest = true
}
return resolved, nil
}
package install
import (
"path/filepath"
"slices"
"sort"
"strings"
"time"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/javaruntime"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/filetrack"
versionpkg "github.com/mclucy/lucy/version"
)
// ResultSchema versions the machine-readable result.
const ResultSchema = 1
// ResultKind identifies the machine-readable install result document.
const ResultKind = "install"
// ResultKindUpgrade identifies a result produced by the upgrade command.
const ResultKindUpgrade = "upgrade"
// Mode records how the operation produced its lock.
type Mode string
const (
ModeReplay Mode = "replay"
ModeResolve Mode = "resolve"
ModeLocked Mode = "locked"
)
// Change names what happened to one package or to the server record.
type Change string
const (
ChangeAdded Change = "added"
ChangeRemoved Change = "removed"
ChangeUpgraded Change = "upgraded"
ChangeDowngraded Change = "downgraded"
ChangeRebuilt Change = "rebuilt"
ChangeChanged Change = "changed"
)
// PackageChange is one difference between the previous lock and the committed
// one, or between no lock and the first one.
type PackageChange struct {
Reference string `json:"reference,omitempty"`
Name string `json:"name"`
Provider catalog.Provider `json:"provider"`
ProjectID string `json:"project_id,omitempty"`
Version versionpkg.Version `json:"version,omitempty"`
Previous versionpkg.Version `json:"previous,omitempty"`
ReleaseID string `json:"release_id,omitempty"`
PreviousReleaseID string `json:"previous_release_id,omitempty"`
Change Change `json:"change"`
Direct bool `json:"direct"`
RequiredBy []string `json:"required_by,omitempty"`
}
// ManifestChange records a declared selector that the operation rewrites.
type ManifestChange struct {
Reference string `json:"reference"`
PreviousSelector versionpkg.Version `json:"previous_selector"`
Selector versionpkg.Version `json:"selector"`
}
// MCDRChange reports a refreshed Python environment for MCDR.
type MCDRChange struct {
Version versionpkg.Version `json:"version"`
Inputs int `json:"inputs"`
PreviousInputs int `json:"previous_inputs"`
}
// ServerChange reports a new or changed server core.
type ServerChange struct {
Change Change `json:"change,omitempty"`
Product server.Product `json:"product,omitempty"`
Minecraft versionpkg.Version `json:"minecraft,omitempty"`
Version versionpkg.Version `json:"version,omitempty"`
Previous versionpkg.Version `json:"previous,omitempty"`
}
// FileChanges counts the managed runtime outputs the commit touched.
type FileChanges struct {
Created int `json:"created"`
Replaced int `json:"replaced"`
Removed int `json:"removed"`
}
// FetchSummary counts artifact transfers: local reuse and network downloads.
type FetchSummary struct {
Cached int `json:"cached"`
Downloaded int `json:"downloaded"`
Bytes int64 `json:"bytes"`
}
// Phase is one timed stage of the operation.
type Phase struct {
Name string `json:"name"`
Elapsed time.Duration `json:"-"`
Seconds float64 `json:"seconds"`
}
// Result reports what one operation decided and changed. It carries facts
// only; the command layer renders them.
type Result struct {
Schema int `json:"schema"`
// Kind names the machine-readable document. Results created by this
// package default it to ResultKind; projections from other operations
// set it explicitly.
Kind string `json:"kind"`
Mode Mode `json:"mode"`
Reason string `json:"reason,omitempty"`
Policy string `json:"policy,omitempty"`
PlanHash string `json:"plan_hash,omitempty"`
Server ServerChange `json:"server"`
Packages []PackageChange `json:"packages"`
ManifestChanges []ManifestChange `json:"manifest_changes,omitempty"`
MCDR *MCDRChange `json:"mcdr,omitempty"`
Files FileChanges `json:"files"`
Fetch FetchSummary `json:"fetch"`
Phases []Phase `json:"phases"`
Diagnostics []filetrack.Diagnostic `json:"diagnostics,omitempty"`
// Java reports the Java runtime that the server runs on. It is nil when
// the operation stopped before it selected one.
Java *javaruntime.Check `json:"java,omitempty"`
Warnings []error `json:"-"`
// WarningMessages carries Warnings into machine-readable output.
WarningMessages []string `json:"warnings,omitempty"`
// TotalPackages counts every package the committed lock records.
TotalPackages int `json:"total_packages"`
// PublishedManifest and PublishedLock report which documents changed.
PublishedManifest bool `json:"published_manifest"`
PublishedLock bool `json:"published_lock"`
}
func newResult() *Result {
return &Result{Schema: ResultSchema, Kind: ResultKind}
}
// CommittedError marks a failure after the installation was committed. The
// published files and documents stay in place; nothing is rolled back.
type CommittedError struct{ Err error }
func (e *CommittedError) Error() string { return e.Err.Error() }
func (e *CommittedError) Unwrap() error { return e.Err }
func (r *Result) addPhase(name string, elapsed time.Duration) {
if r == nil {
return
}
r.Phases = append(r.Phases, Phase{
Name: name,
Elapsed: elapsed,
Seconds: elapsed.Seconds(),
})
}
// Changed reports whether the operation changed the workspace at all.
func (r *Result) Changed() bool {
if r == nil {
return false
}
return r.Server.Change != "" ||
len(r.Packages) > 0 ||
len(r.ManifestChanges) > 0 ||
r.MCDR != nil ||
r.PublishedManifest ||
r.PublishedLock ||
r.Files.Created > 0 ||
r.Files.Replaced > 0 ||
r.Files.Removed > 0
}
func (r *Result) applyChanges(
before, after *document.Lock,
d *document.Manifest,
) {
if r == nil || after == nil {
return
}
r.TotalPackages = len(after.Packages)
r.Server = serverChange(before, after)
r.Packages = packageChanges(before, after, d)
}
func serverChange(before, after *document.Lock) ServerChange {
current := ServerChange{
Product: after.Server.Product,
Minecraft: after.Server.Minecraft,
Version: after.Server.CoreVersion,
}
switch {
case before == nil:
current.Change = ChangeAdded
case before.Server.Product != after.Server.Product ||
before.Server.CoreVersion != after.Server.CoreVersion:
current.Change = ChangeUpgraded
current.Previous = before.Server.CoreVersion
}
return current
}
type packageView struct {
name string
direct bool
version versionpkg.Version
releaseID string
fileID string
dialect versionpkg.Dialect
}
func packageChanges(
before, after *document.Lock,
d *document.Manifest,
) []PackageChange {
declared := map[requirementKey]document.Requirement{}
if d != nil {
declared = declaredRequirements(d)
}
oldPackages := packageViews(before, declared)
newPackages := packageViews(after, declared)
dependents := dependencyNames(after, newPackages)
changes := make([]PackageChange, 0, len(oldPackages)+len(newPackages))
for id, current := range newPackages {
previous, existed := oldPackages[id]
change := PackageChange{
Name: current.name,
Change: ChangeAdded,
Direct: current.direct,
RequiredBy: dependents[id],
Version: current.version,
ReleaseID: current.releaseID,
}
if pkg := findPackage(after, id); pkg != nil {
change.Reference = declaredReference(pkg, declared)
change.Provider = pkg.Provider
change.ProjectID = pkg.ProjectID
}
if existed {
switch {
case previous.version == current.version:
if previous.releaseID == current.releaseID && previous.fileID == current.fileID {
continue
}
change.Change = ChangeRebuilt
change.Previous = previous.version
change.PreviousReleaseID = previous.releaseID
default:
change.Previous = previous.version
change.PreviousReleaseID = previous.releaseID
comparison, comparable := comparePackageVersions(previous, current)
switch {
case !comparable:
change.Change = ChangeChanged
case comparison < 0:
change.Change = ChangeUpgraded
case comparison > 0:
change.Change = ChangeDowngraded
default:
change.Change = ChangeChanged
}
}
}
changes = append(changes, change)
}
for id, previous := range oldPackages {
if _, still := newPackages[id]; still {
continue
}
change := PackageChange{
Name: previous.name,
Change: ChangeRemoved,
Direct: previous.direct,
Version: previous.version,
Previous: previous.version,
PreviousReleaseID: previous.releaseID,
}
if pkg := findPackage(before, id); pkg != nil {
change.Provider = pkg.Provider
change.ProjectID = pkg.ProjectID
if reference := enabledReference(pkg); reference != "" {
change.Reference = reference
change.Direct = true
}
}
changes = append(changes, change)
}
sort.SliceStable(changes, func(i, j int) bool {
a, b := &changes[i], &changes[j]
if a.Direct != b.Direct {
return a.Direct
}
return a.Name < b.Name
})
return changes
}
func packageViews(
doc *document.Lock,
declared map[requirementKey]document.Requirement,
) map[string]packageView {
views := map[string]packageView{}
if doc == nil {
return views
}
names := packageNames(doc)
for i := range doc.Packages {
p := &doc.Packages[i]
id := lockedPackageKey(p)
reference := declaredReference(p, declared)
if reference == "" {
reference = enabledReference(p)
}
name := names[id]
if reference != "" {
name = referenceName(reference)
}
direct := reference != ""
if len(declared) == 0 {
direct = len(p.Requirements) > 0
}
views[id] = packageView{
name: name,
direct: direct,
version: p.Version,
releaseID: p.ReleaseID,
fileID: p.FileID,
dialect: packageVersionDialect(p),
}
}
return views
}
// enabledReference returns the reference a package records for itself. A
// package the current manifest no longer names, such as one being removed,
// still carries the requirement that declared it.
func enabledReference(p *document.Package) string {
for _, binding := range p.Requirements {
if binding.Enabled {
return binding.Reference
}
}
if len(p.Requirements) > 0 {
return p.Requirements[0].Reference
}
return ""
}
// packageNames resolves the display name of packages that no requirement
// declares: the module that dependents refer to, else the project identity.
func packageNames(doc *document.Lock) map[string]string {
names := map[string]string{}
if doc == nil {
return names
}
for i := range doc.Packages {
p := &doc.Packages[i]
id := lockedPackageKey(p)
if p.ProjectID != "" {
names[id] = p.ProjectID
} else {
names[id] = strings.TrimSuffix(p.Filename, filepath.Ext(p.Filename))
}
}
moduleOwner := moduleOwners(doc)
for i := range doc.Packages {
p := &doc.Packages[i]
for _, module := range p.Modules {
for _, dependency := range module.Dependencies {
target := dependencyTarget(p, dependency, moduleOwner, doc)
if target == "" {
continue
}
used := dependency.ModuleID
if used == "" {
used = dependency.ID
}
if used != "" {
names[target] = used
}
}
}
}
return names
}
func dependencyNames(
doc *document.Lock,
views map[string]packageView,
) map[string][]string {
dependents := map[string][]string{}
if doc == nil {
return dependents
}
moduleOwner := moduleOwners(doc)
for i := range doc.Packages {
p := &doc.Packages[i]
source := views[lockedPackageKey(p)].name
for _, module := range p.Modules {
for _, dependency := range module.Dependencies {
target := dependencyTarget(p, dependency, moduleOwner, doc)
if target == "" {
continue
}
if slices.Contains(dependents[target], source) {
continue
}
dependents[target] = append(dependents[target], source)
}
}
}
for target := range dependents {
slices.Sort(dependents[target])
}
return dependents
}
func moduleOwners(doc *document.Lock) map[string]string {
owners := map[string]string{}
if doc == nil {
return owners
}
for i := range doc.Packages {
p := &doc.Packages[i]
id := lockedPackageKey(p)
for _, module := range p.Modules {
owners[moduleLookupKey(p, module.ID)] = id
for _, provided := range module.Provides {
owners[moduleLookupKey(p, provided)] = id
}
}
}
return owners
}
func moduleLookupKey(p *document.Package, moduleID string) string {
return string(p.Environment) + "|" + string(p.Ecosystem) + "|" + strings.ToLower(moduleID)
}
func dependencyTarget(
source *document.Package,
dependency catalog.DescriptorDependency,
moduleOwner map[string]string,
doc *document.Lock,
) string {
if dependency.Provider != "" && dependency.ProjectID != "" {
for i := range doc.Packages {
candidate := &doc.Packages[i]
if candidate.Environment == source.Environment &&
candidate.Ecosystem == source.Ecosystem &&
candidate.Provider == dependency.Provider &&
candidate.ProjectID == dependency.ProjectID {
return lockedPackageKey(candidate)
}
}
}
moduleID := dependency.ModuleID
if moduleID == "" {
moduleID = dependency.ID
}
if moduleID == "" {
return ""
}
return moduleOwner[moduleLookupKey(source, moduleID)]
}
func lockedPackageKey(p *document.Package) string {
identity := p.ProjectID
if identity == "" {
identity = p.Filename
}
return strings.Join(
[]string{
string(p.Environment),
string(p.Ecosystem),
string(p.Provider),
identity,
},
"|",
)
}
func findPackage(doc *document.Lock, id string) *document.Package {
if doc == nil {
return nil
}
for i := range doc.Packages {
if lockedPackageKey(&doc.Packages[i]) == id {
return &doc.Packages[i]
}
}
return nil
}
func declaredReference(
p *document.Package,
declared map[requirementKey]document.Requirement,
) string {
for _, binding := range p.Requirements {
key := requirementKey{p.Environment, p.Ecosystem, binding.Reference}
if _, ok := declared[key]; ok {
return binding.Reference
}
}
return ""
}
func referenceName(reference string) string {
_, project, err := document.ParseReference(reference)
if err != nil {
return reference
}
return project
}
func countDeclared(d *document.Manifest) int {
if d == nil {
return 0
}
count := 0
for _, packages := range d.Server.Packages {
for _, requirement := range packages {
if requirement.IsEnabled() {
count++
}
}
}
if d.MCDR != nil {
for _, requirement := range d.MCDR.Packages {
if requirement.IsEnabled() {
count++
}
}
}
return count
}
func packageVersionDialect(p *document.Package) versionpkg.Dialect {
for _, module := range p.Modules {
if module.VersionDialect.Valid() {
return module.VersionDialect
}
}
switch p.Ecosystem {
case catalog.EcosystemFabric:
return versionpkg.DialectFabric
case catalog.EcosystemForge, catalog.EcosystemNeoForge:
return versionpkg.DialectMaven
case catalog.EcosystemMCDR:
return versionpkg.DialectMCDR
case catalog.EcosystemBukkit, catalog.EcosystemPaper:
return versionpkg.DialectOpaque
default:
return versionpkg.DialectUnknown
}
}
func comparePackageVersions(previous, current packageView) (int, bool) {
dialect := current.dialect
if !dialect.Valid() {
dialect = previous.dialect
}
if !dialect.Valid() {
return 0, false
}
before, err := versionpkg.ParseVersion(previous.version, dialect)
if err != nil {
return 0, false
}
after, err := versionpkg.ParseVersion(current.version, dialect)
if err != nil {
return 0, false
}
return versionpkg.Compare(before, after)
}
package install
import (
"context"
"errors"
"fmt"
"path/filepath"
"slices"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/artifactstore"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/internal/workspacewrite"
"github.com/mclucy/lucy/javaruntime"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/serverregistry"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/routing"
)
// Source is the routing capability installation consumes. Configured routing
// implements it; installation never selects a provider or transport on its
// own.
type Source interface {
Candidates(
ctx context.Context,
registry catalog.Registry,
project, minecraft string,
ecosystem catalog.Ecosystem,
) ([]upstream.Candidate, error)
Metadata(ctx context.Context, endpoint string, target any) error
ResolveServerProduct(
ctx context.Context,
request routing.ServerProductRequest,
acquire func(*catalog.Artifact) (artifact.MaterializedFile, error),
) (routing.ServerProductResolution, error)
}
// Service owns one installation pipeline: configured routing, the artifact
// cache, tracked-file provisioning, the Python host, and the diagnostic
// logger. Every entry point receives its root and request options explicitly.
type Service struct {
source Source
network *cache.Client
tracker *filetrack.Service
python *PythonHost
registry *serverregistry.Registry
logger *log.Logger
githubProxy string
}
// NewService constructs the installation service from its explicit
// dependencies. network performs artifact transfers; tracker provisions
// tracked files; python supplies interpreter and process capabilities;
// registry records successful installations for the artifact store's
// retention sweep; githubProxy mirrors release asset downloads when
// non-empty.
func NewService(
source Source,
network *cache.Client,
tracker *filetrack.Service,
python *PythonHost,
registry *serverregistry.Registry,
logger *log.Logger,
githubProxy string,
) *Service {
return &Service{
source: source,
network: network,
tracker: tracker,
python: python,
registry: registry,
logger: logger,
githubProxy: githubProxy,
}
}
// registerServer records the workspace before any artifact enters the store.
// A successful installation therefore always has a registry entry whose lock
// the artifact store's retention sweep can read. Registration failure fails
// the installation instead of reporting success that the sweep cannot trace.
func (s *Service) registerServer(root string, result *Result) error {
if s.registry == nil {
return errors.New("server registry is unavailable; cannot record this installation for artifact retention")
}
duplicates, err := s.registry.RegisterInstallation(root)
if err != nil {
return fmt.Errorf("register server before installation: %w", err)
}
if result != nil {
for _, duplicate := range duplicates {
result.Warnings = append(result.Warnings, fmt.Errorf(
"server name %q is also registered at %s; --server cannot select an ambiguous name",
duplicate.Name,
duplicate.Path,
))
}
}
return nil
}
// Resolve builds the lock document that answers d without staging or
// committing the server tree. It records the declared packages alone, because
// the workspace the manifest describes already holds a working set that
// catalogue dependencies must not extend. Artifact bytes still enter the
// artifact store because the lock pins their hashes. It returns the
// resolution facts alongside the lock. The caller must hold the workspace
// writer lock.
func (s *Service) Resolve(
ctx context.Context,
root string,
d *document.Manifest,
old *document.Lock,
opts Options,
) (*document.Lock, *Result, error) {
result := newResult()
result.Mode = ModeResolve
result.Reason = "lock resolved"
absolute, err := filepath.Abs(root)
if err != nil {
return nil, result, err
}
if d == nil {
return nil, result, fmt.Errorf("manifest required to resolve a lock")
}
opts.declaredOnly = true
if err := workspacewrite.SafePath(absolute, ".lucy"); err != nil {
return nil, result, err
}
fingerprint, err := d.Fingerprint()
if err != nil {
return nil, result, err
}
if old != nil && !opts.WithOptional && old.ManifestFingerprint == fingerprint {
s.logger.Debug("lock resolution reused current lock", "reason", "manifest unchanged")
result.Mode = ModeReplay
result.Reason = "manifest unchanged; lock reused"
return old, result, nil
}
store := s.newDownloadStore(ctx, opts, result)
resolved, err := s.resolveInstall(ctx, absolute, d, old, fingerprint, &opts, store, result)
if err != nil {
return nil, result, err
}
if err := resolved.Validate(); err != nil {
return nil, result, err
}
return resolved, result, nil
}
// PlanInstall resolves a lock without staging or committing the server tree.
// It holds the artifact-store operation lease and the workspace writer lock
// only while reading and resolving, so a collection sweep cannot reclaim bytes
// the resolution materializes. Both locks are released when planning returns;
// the plan itself reserves no artifacts, and ApplyPlan acquires the lease
// again before using it.
func (s *Service) PlanInstall(
ctx context.Context,
root string,
d *document.Manifest,
old *document.Lock,
opts Options,
) (plan *Plan, resultErr error) {
absolute, err := filepath.Abs(root)
if err != nil {
return nil, err
}
root = absolute
lease, err := artifactstore.AcquireOperation()
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, lease.Close()) }()
if err := workspacewrite.SafePath(root, ".lucy"); err != nil {
return nil, err
}
writer, err := workspacewrite.Lock(root, s.logger)
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
_, baseLock, err := preflightLocked(root, d, old, opts, false, s.logger)
if err != nil {
return nil, err
}
previous, err := recoveredLock(old, baseLock)
if err != nil {
return nil, err
}
result := newResult()
store := s.newDownloadStore(ctx, opts, result)
// The plan answers the recovered workspace: the previous lock is always the
// recovered one, so a caller that read documents before recovery cannot
// hand the resolver a stale base. The manifest stays the caller's document
// because a publish plan carries an intended edit; ApplyPlan rejects a
// non-publishing plan whose manifest does not match the recorded base.
resolved, err := s.resolveLock(ctx, root, d, previous, &opts, store, result)
if err != nil {
return nil, err
}
result.PublishedManifest = opts.PublishManifest
result.PublishedLock = opts.publishLock
baseManifestFingerprint, baseLockFingerprint, err := workspaceFingerprints(root)
if err != nil {
return nil, err
}
return &Plan{
Manifest: d,
Previous: previous,
Resolved: resolved,
PublishManifest: opts.PublishManifest,
PublishLock: opts.publishLock,
BaseManifestFingerprint: baseManifestFingerprint,
BaseLockFingerprint: baseLockFingerprint,
Result: result,
}, nil
}
// ApplyPlan stages and commits a previously resolved plan. It rechecks the
// workspace state before staging, so a plan cannot overwrite a concurrent
// manifest or lock edit.
func (s *Service) ApplyPlan(
ctx context.Context,
root string,
plan *Plan,
opts Options,
) (result *Result, resultErr error) {
if plan == nil || plan.Resolved == nil {
return nil, fmt.Errorf("installation plan is required")
}
absolute, err := filepath.Abs(root)
if err != nil {
return nil, err
}
root = absolute
lease, err := artifactstore.AcquireOperation()
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, lease.Close()) }()
if err := workspacewrite.SafePath(root, ".lucy"); err != nil {
return nil, err
}
writer, err := workspacewrite.Lock(root, s.logger)
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
preflightOptions := opts
preflightOptions.PublishManifest = plan.PublishManifest
if _, _, err := preflightLocked(root, plan.Manifest, plan.Previous, preflightOptions, true, s.logger); err != nil {
return nil, err
}
if err := checkPlannedDocuments(plan); err != nil {
return nil, err
}
if err := checkPlannedState(root, plan); err != nil {
return nil, err
}
if err := s.registerServer(root, plan.Result); err != nil {
return nil, err
}
// The isolated MCDReforged environment is keyed by its Python requirements,
// so staging provisions a fresh environment whenever this list changes.
if previous := plan.Previous; previous != nil && previous.MCDR != nil &&
plan.Resolved.MCDR != nil {
before := mcdrPythonRequirements(previous)
after := mcdrPythonRequirements(plan.Resolved)
if !slices.Equal(before, after) {
if plan.Result == nil {
plan.Result = newResult()
}
plan.Result.MCDR = &MCDRChange{
Version: plan.Resolved.MCDR.Version,
Inputs: len(after),
PreviousInputs: len(before),
}
}
}
result = plan.Result
if result == nil {
result = newResult()
result.applyChanges(plan.Previous, plan.Resolved, plan.Manifest)
}
store := s.newDownloadStore(ctx, opts, result)
opts.PublishManifest = plan.PublishManifest
opts.publishLock = plan.PublishLock
diagnostics, err := s.stageInstall(ctx, root, plan.Manifest, plan.Resolved, store, opts, result)
result.Diagnostics = diagnostics
return result, err
}
// Sync converges the workspace to its manifest and lock. A missing manifest or
// lock is allowed only when the other document can drive the operation.
func (s *Service) Sync(
ctx context.Context,
root string,
d *document.Manifest,
old *document.Lock,
opts Options,
) (*Result, error) {
result := newResult()
diagnostics, resultErr := s.syncInstall(ctx, root, d, old, opts, result)
result.Diagnostics = diagnostics
return result, resultErr
}
// Materializer returns a verified artifact materializer and the fetch summary
// it updates. Callers must hold the workspace writer lock while resolving.
func (s *Service) Materializer(
ctx context.Context,
opts Options,
) (func(*catalog.Artifact) (artifact.MaterializedFile, error), *FetchSummary) {
summary := &FetchSummary{}
store := &downloadStore{
ctx: ctx,
offline: opts.Offline,
trace: opts.Trace,
fetch: summary,
network: s.network,
logger: s.logger,
githubProxy: s.githubProxy,
injected: opts.Materialize,
}
return store.materialize, summary
}
// CheckJava reports whether an installed Java runtime satisfies the lock. It
// never installs or modifies a runtime.
func (s *Service) CheckJava(
ctx context.Context,
root string,
locked *document.Lock,
) (*javaruntime.Check, error) {
return javaruntime.Select(ctx, root, javaruntime.RequirementOf(locked, s.logger), s.logger)
}
package install
import (
"io"
"time"
"github.com/mclucy/lucy/resolve"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/version"
)
// PhaseInfo describes a finished stage. Counts are facts the renderer turns
// into prose; the backend never formats them.
type PhaseInfo struct {
Name string
Elapsed time.Duration
Count int
Total int
Product server.Product
Version version.Version
Minecraft version.Version
}
// Trace receives facts about an operation while it runs. Every field is
// optional: a nil field or a nil Trace reports nothing. The caller that
// installs a Trace presents the facts; this package never formats them.
type Trace struct {
PhaseStarted func(name string)
PhaseDone func(phase PhaseInfo)
FetchStarted func(name string, size int64)
FetchProgress func(name string, read, total int64)
FetchDone func(name string, fromCache bool)
// ProcessOutput receives the output of subprocesses that the operation
// runs, such as installer processors, Paperclip, and pip.
ProcessOutput io.Writer
Resolve resolve.Trace
}
func (t *Trace) phaseStarted(name string) {
if t != nil && t.PhaseStarted != nil {
t.PhaseStarted(name)
}
}
func (t *Trace) phaseDone(phase PhaseInfo) {
if t != nil && t.PhaseDone != nil {
t.PhaseDone(phase)
}
}
func (t *Trace) fetchStarted(name string, size int64) {
if t != nil && t.FetchStarted != nil {
t.FetchStarted(name, size)
}
}
func (t *Trace) fetchProgress(name string, read, total int64) {
if t != nil && t.FetchProgress != nil {
t.FetchProgress(name, read, total)
}
}
func (t *Trace) fetchDone(name string, fromCache bool) {
if t != nil && t.FetchDone != nil {
t.FetchDone(name, fromCache)
}
}
func (t *Trace) processOutput() io.Writer {
if t == nil || t.ProcessOutput == nil {
return io.Discard
}
return t.ProcessOutput
}
func (t *Trace) resolveTrace() resolve.Trace {
if t == nil {
return resolve.Trace{}
}
return t.Resolve
}
package install
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/internal/workspacewrite"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/workspace/discovery"
"gopkg.in/yaml.v3"
)
type (
receiptEntry struct {
Path string `yaml:"path"`
SHA256 string `yaml:"sha256"`
Mode uint32 `yaml:"mode"`
}
installReceipt struct {
Version int `yaml:"version"`
LockFingerprint string `yaml:"lock_fingerprint"`
Entries []receiptEntry `yaml:"entries"`
}
)
// RecordAdoptedPackages records package files that initialization identified
// from upstream metadata as owned runtime outputs. Initialization stages
// nothing, so the receipt answers no lock; ownership only lets a later
// installation replace or prune these files instead of refusing them as
// unmanaged files. Packages initialization left unidentified, files outside
// the workspace, and paths that traverse symlinks stay unowned.
//
// A receipt that does not answer the published lock cannot describe the
// installation either, so its fingerprint is cleared as well; a receipt that
// still answers the lock keeps it.
func RecordAdoptedPackages(root string, d *document.Lock, paths []string) error {
fingerprint, err := ResolutionFingerprint(d)
if err != nil {
return err
}
receiptPath := filepath.Join(root, ".lucy", "install.yaml")
receipt := installReceipt{Version: 1, Entries: []receiptEntry{}}
raw, err := os.ReadFile(receiptPath)
exists := err == nil
switch {
case exists:
if err := yaml.Unmarshal(raw, &receipt); err != nil {
return err
}
case !os.IsNotExist(err):
return err
}
answersLock := exists && receipt.LockFingerprint == fingerprint
entries := make(map[string]receiptEntry, len(receipt.Entries)+len(paths))
for _, entry := range receipt.Entries {
entries[entry.Path] = entry
}
recorded := false
for _, path := range paths {
absolute, err := filepath.Abs(path)
if err != nil {
return err
}
relative, err := filepath.Rel(root, absolute)
if err != nil {
continue
}
if err := workspacewrite.SafePath(root, relative); err != nil {
// Lucy does not own files outside the workspace, and installation
// refuses to prune through symlinks.
continue
}
relative = filepath.ToSlash(relative)
info, err := os.Stat(absolute)
if err != nil {
return err
}
hashes, err := DigestFile(absolute, nil)
if err != nil {
return err
}
entry := receiptEntry{
Path: relative,
SHA256: hashes["sha256"],
Mode: uint32(info.Mode().Perm()),
}
if entries[relative] == entry {
continue
}
entries[relative] = entry
recorded = true
}
if !recorded && (receipt.LockFingerprint == "" || answersLock) {
return nil
}
// Initialization staged nothing, so the receipt cannot answer the lock it
// publishes.
receipt.Version = 1
receipt.LockFingerprint = ""
receipt.Entries = make([]receiptEntry, 0, len(entries))
for _, entry := range entries {
receipt.Entries = append(receipt.Entries, entry)
}
slices.SortFunc(receipt.Entries, func(a, b receiptEntry) int {
return strings.Compare(a.Path, b.Path)
})
receiptBytes, err := yaml.Marshal(receipt)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(receiptPath), 0o755); err != nil {
return err
}
return fsutil.AtomicWrite(receiptPath, receiptBytes, 0o600)
}
// receiptAdoptsRuntime reports whether the observed runtime stays adoptable. A
// receipt that answers a lock owns the runtime it installed. Initialization
// writes a receipt without a lock fingerprint, so it records the packages it
// identified while the observed runtime remains the installation to adopt.
func receiptAdoptsRuntime(root string) (bool, error) {
raw, err := os.ReadFile(filepath.Join(root, ".lucy", "install.yaml"))
if os.IsNotExist(err) {
return true, nil
}
if err != nil {
return false, err
}
var receipt installReceipt
if err := yaml.Unmarshal(raw, &receipt); err != nil {
return false, err
}
return receipt.LockFingerprint == "", nil
}
// adoptedOutputPaths reports the workspace-relative members of the runtime the
// lock names, when observation selects that runtime. Installation replaces
// these files instead of refusing them: they belong to the installation that
// initialization adopted, not to unrelated work.
func adoptedOutputPaths(root string, server document.LockedServer, logger *log.Logger) map[string]bool {
serverDir := filepath.Join(root, filepath.FromSlash(server.Directory))
observed := discovery.Discover(serverDir)
declaration := &discovery.Declaration{
Product: server.Product,
Minecraft: server.Minecraft,
CoreVersion: server.CoreVersion,
}
if err := observed.Select(discovery.Request{Declared: declaration}); err != nil {
logger.Debug("no adopted installation to reconcile", "reason", err)
return nil
}
selected := observed.Selected()
if selected == nil || selected.Installation == nil {
return nil
}
paths := make(map[string]bool, len(selected.Installation.Files))
for _, file := range selected.Installation.Files {
relative, err := filepath.Rel(root, file.Path)
if err != nil || !filepath.IsLocal(relative) {
continue
}
paths[filepath.ToSlash(relative)] = true
}
return paths
}
func applyCommit(root, stage string, d *document.Manifest, l *document.Lock, publish bool, overwritePaths, adoptedPaths map[string]bool, logger *log.Logger) (FileChanges, error) {
var counts FileChanges
resultErr := applyCommitFiles(root, stage, d, l, publish, overwritePaths, adoptedPaths, &counts, logger)
return counts, resultErr
}
func applyCommitFiles(root, stage string, d *document.Manifest, l *document.Lock, publish bool, overwritePaths, adoptedPaths map[string]bool, counts *FileChanges, logger *log.Logger) (resultErr error) {
receiptPath := filepath.Join(root, ".lucy", "install.yaml")
previous := installReceipt{}
raw, err := os.ReadFile(receiptPath)
receiptExisted := err == nil
if receiptExisted {
if err := yaml.Unmarshal(raw, &previous); err != nil {
return err
}
} else if !os.IsNotExist(err) {
return err
}
owned := map[string]string{}
occupantHashes := map[string]string{}
for _, entry := range previous.Entries {
owned[entry.Path] = entry.SHA256
}
next := installReceipt{Version: 1, Entries: []receiptEntry{}}
lockFingerprint, err := ResolutionFingerprint(l)
if err != nil {
return err
}
next.LockFingerprint = lockFingerprint
tracked := trackedPaths(l)
sources := map[string]string{}
sourceImages := map[string]workspacewrite.InstallImage{}
err = filepath.WalkDir(stage, func(p string, e os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if e.IsDir() {
return nil
}
relative, err := filepath.Rel(stage, p)
if err != nil {
return err
}
relative = filepath.ToSlash(relative)
if strings.Contains(relative, "/.bootstrap-data/") || strings.HasPrefix(relative, ".bootstrap-data/") {
return nil
}
if err := document.SafeRelativePath(relative); err != nil {
return err
}
target := filepath.Join(root, relative)
mutableDefault := filepath.Base(relative) == "config.yml" || filepath.Base(relative) == "user_jvm_args.txt"
if tracked[strings.ToLower(relative)] {
if !mutableDefault {
return fmt.Errorf("tracked file %s collides with managed runtime output", relative)
}
return nil
}
if mutableDefault {
if _, err := os.Stat(target); err == nil {
return nil
}
}
hashes, err := DigestFile(p, nil)
if err != nil {
return err
}
info, err := e.Info()
if err != nil {
return err
}
if _, err := os.Lstat(target); err == nil {
if info, err := os.Lstat(target); err != nil || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("refuse symlink output %s", relative)
}
actual, err := DigestFile(target, nil)
if err != nil {
return err
}
occupantHashes[relative] = actual["sha256"]
if actual["sha256"] != hashes["sha256"] && actual["sha256"] != owned[relative] {
switch {
case overwritePaths[relative]:
logger.Debug(
"overwriting occupied output",
"path", relative,
"reason", "overwrite requested",
)
case adoptedPaths[relative]:
logger.Debug(
"adopting occupied output",
"path", relative,
"reason", "occupied by the adopted installation",
)
default:
return fmt.Errorf("unowned or modified output conflict: %s", relative)
}
}
} else if !os.IsNotExist(err) {
return err
}
mode := os.FileMode(0o644)
if !mutableDefault {
mode = info.Mode().Perm()
next.Entries = append(next.Entries, receiptEntry{
Path: relative,
SHA256: hashes["sha256"],
Mode: uint32(mode),
})
}
sourceImages[relative] = workspacewrite.InstallImage{
SHA256: hashes["sha256"],
Mode: effectiveInstallMode(mode),
}
sources[relative] = p
return nil
})
if err != nil {
return err
}
pruneExpected := map[string]string{}
for _, old := range previous.Entries {
if tracked[strings.ToLower(old.Path)] {
continue
}
if _, ok := sources[old.Path]; ok {
continue
}
p := filepath.Join(root, old.Path)
if _, err := os.Stat(p); os.IsNotExist(err) {
continue
}
actual, err := DigestFile(p, nil)
if err != nil {
return err
}
if actual["sha256"] != old.SHA256 {
return fmt.Errorf("modified owned output cannot be pruned: %s", old.Path)
}
pruneExpected[old.Path] = old.SHA256
}
receiptBytes, err := yaml.Marshal(next)
if err != nil {
return err
}
lockPlan, err := installDocumentPlanFor(
root,
document.LockFilename,
0o644,
func() ([]byte, error) { return document.LockFile(root).Snapshot(l) },
l.MergeBytes,
)
if err != nil {
return err
}
var manifestPlan *installDocumentPlan
if publish {
manifestPlan, err = installDocumentPlanFor(
root,
document.ManifestFilename,
0o644,
func() ([]byte, error) { return document.ManifestFile(root).Snapshot(d) },
d.MergeBytes,
)
if err != nil {
return err
}
}
receiptMode, err := installFileMode(receiptPath, 0o600)
if err != nil {
return err
}
receiptAfter := installBytesImage(receiptBytes, receiptMode)
var receiptBase *workspacewrite.InstallImage
if receiptExisted {
receiptBase = installBytesImage(raw, receiptMode)
}
afterImages := make(map[string]*workspacewrite.InstallImage, len(sources)+3)
for name, image := range sourceImages {
if installPathReserved(root, name) {
return fmt.Errorf("staged output %s is reserved", name)
}
image := image
afterImages[name] = &image
}
planAfter := func(name string, image *workspacewrite.InstallImage) error {
if installPathMatchesReserved(
root, name, workspacewrite.InstallJournalName,
) {
return fmt.Errorf("installation path %s is reserved", name)
}
if _, exists := afterImages[name]; exists {
return fmt.Errorf("installation path %s has conflicting owners", name)
}
afterImages[name] = image
return nil
}
if err := planAfter(document.LockFilename, &lockPlan.Image); err != nil {
return err
}
if publish {
if err := planAfter(document.ManifestFilename, &manifestPlan.Image); err != nil {
return err
}
}
if err := planAfter(".lucy/install.yaml", receiptAfter); err != nil {
return err
}
baseImages := map[string]*workspacewrite.InstallImage{
document.LockFilename: lockPlan.Base,
".lucy/install.yaml": receiptBase,
}
documentAfter := map[string]*workspacewrite.InstallImage{
document.LockFilename: &lockPlan.Image,
".lucy/install.yaml": receiptAfter,
}
if publish {
baseImages[document.ManifestFilename] = manifestPlan.Base
documentAfter[document.ManifestFilename] = &manifestPlan.Image
}
for _, old := range previous.Entries {
if tracked[strings.ToLower(old.Path)] {
continue
}
if _, ok := sources[old.Path]; ok {
continue
}
if _, err := os.Stat(filepath.Join(root, old.Path)); err != nil {
if os.IsNotExist(err) {
continue
}
return err
}
if installPathMatchesReserved(
root, old.Path, document.ManifestFilename,
) {
return fmt.Errorf("installation receipt path %s is reserved", old.Path)
}
if err := planAfter(old.Path, nil); err != nil {
return err
}
}
names := make([]string, 0, len(afterImages))
for name := range afterImages {
names = append(names, name)
}
slices.Sort(names)
if err := rejectInstallPathAliases(root, names); err != nil {
return err
}
backup, err := os.MkdirTemp(filepath.Join(root, ".lucy"), "rollback-*")
if err != nil {
return err
}
journalPublished := false
defer func() {
if journalPublished {
return
}
if err := os.RemoveAll(backup); err != nil {
logger.Warn(fmt.Errorf("remove unused installation backup: %w", err))
}
}()
backupRelative := filepath.ToSlash(
filepath.Join(".lucy", filepath.Base(backup)),
)
journal := workspacewrite.InstallJournal{
Backup: backupRelative,
Entries: make([]workspacewrite.InstallJournalEntry, 0, len(names)),
}
prepareErr := func() (resultErr error) {
files, err := os.OpenRoot(root)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, files.Close()) }()
for _, name := range names {
if err := workspacewrite.SafePath(root, name); err != nil {
return err
}
target := filepath.Join(root, name)
entry := workspacewrite.InstallJournalEntry{
Path: name,
After: afterImages[name],
}
info, err := files.Lstat(name)
switch {
case err == nil:
if !info.Mode().IsRegular() {
return fmt.Errorf("installation target %s is not a regular file", name)
}
hashes, err := DigestFile(target, nil)
if err != nil {
return err
}
before := workspacewrite.InstallImage{
SHA256: hashes["sha256"],
Mode: effectiveInstallMode(info.Mode().Perm()),
}
entry.Before = &before
case errors.Is(err, os.ErrNotExist):
default:
return err
}
if base, planned := baseImages[name]; planned {
if !workspacewrite.InstallImagesEqual(entry.Before, base) {
return fmt.Errorf(
"installation path %s changed while planning",
name,
)
}
}
if entry.Before == nil && entry.After == nil {
continue
}
if entry.After == nil {
expected, validated := pruneExpected[name]
if !validated || entry.Before == nil ||
entry.Before.SHA256 != expected {
return fmt.Errorf(
"modified or unowned output cannot be pruned: %s",
name,
)
}
}
if entry.Before != nil && entry.After != nil &&
workspacewrite.InstallImagesEqual(entry.Before, entry.After) {
continue
}
if entry.Before != nil {
if err := preserveInstallPreImage(
files, backup, backupRelative, *entry.Before, target,
); err != nil {
return err
}
}
journal.Entries = append(journal.Entries, entry)
}
return nil
}()
if prepareErr != nil {
return prepareErr
}
journalBytes, err := yaml.Marshal(journal)
if err != nil {
return err
}
journalPath := filepath.Join(root, workspacewrite.InstallJournalName)
if err := fsutil.AtomicWrite(journalPath, journalBytes, 0o600); err != nil {
// AtomicWrite can return a sync error after the journal replacement was
// published. Keep the backup while any journal remains, so recovery can
// still verify and remove it.
if _, statErr := os.Lstat(journalPath); statErr == nil {
journalPublished = true
}
return err
}
journalPublished = true
logger.Debug(
"installation transaction journaled",
"journal", journalPath,
"backup", journal.Backup,
"paths", len(journal.Entries),
)
mutations := make(map[string]workspacewrite.InstallJournalEntry, len(journal.Entries))
for _, entry := range journal.Entries {
mutations[entry.Path] = entry
}
failed := true
defer func() {
if !failed {
return
}
committed, _ := workspacewrite.InstallJournalCommitted(root)
resultErr = errors.Join(resultErr, workspacewrite.RecoverInstall(root, logger))
if committed && resultErr != nil {
resultErr = &CommittedError{Err: fmt.Errorf(
"installation committed; %w", resultErr,
)}
}
}()
applyErr := func() (resultErr error) {
files, err := os.OpenRoot(root)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, files.Close()) }()
for name, source := range sources {
entry, mutated := mutations[name]
if !mutated {
continue
}
image := sourceImages[name]
_, statErr := files.Lstat(name)
exists := statErr == nil
if !exists {
counts.Created++
} else if occupantHashes[name] != image.SHA256 {
counts.Replaced++
}
parent := filepath.Dir(name)
if parent != "." {
if err := files.MkdirAll(parent, 0o755); err != nil {
return err
}
}
input, err := os.Open(source)
if err != nil {
return err
}
copyErr := fsutil.WriteRoot(
files,
name,
input,
fsutil.WriteOptions{
Mode: os.FileMode(entry.After.Mode),
Replace: true,
Check: installBeforeCheck(files, entry),
},
)
closeErr := input.Close()
if copyErr != nil || closeErr != nil {
return errors.Join(copyErr, closeErr)
}
}
pruned := 0
for _, old := range previous.Entries {
if tracked[strings.ToLower(old.Path)] {
continue
}
if _, ok := sources[old.Path]; ok {
continue
}
entry, mutated := mutations[old.Path]
if !mutated {
continue
}
current, err := observeInstallPath(files, old.Path)
if err != nil {
return err
}
if !workspacewrite.InstallImagesEqual(current, entry.Before) {
return fmt.Errorf(
"installation output %s changed before pruning",
old.Path,
)
}
if err := files.Remove(old.Path); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return err
}
pruned++
}
counts.Removed = pruned
if publish {
if entry, mutated := mutations[document.ManifestFilename]; mutated {
if err := publishInstallBytes(
files,
document.ManifestFilename,
manifestPlan.Data,
&manifestPlan.Image,
entry,
); err != nil {
return err
}
}
}
if entry, mutated := mutations[document.LockFilename]; mutated {
if err := publishInstallBytes(
files,
document.LockFilename,
lockPlan.Data,
&lockPlan.Image,
entry,
); err != nil {
return err
}
}
if entry, mutated := mutations[".lucy/install.yaml"]; mutated {
if err := publishInstallBytes(
files,
".lucy/install.yaml",
receiptBytes,
receiptAfter,
entry,
); err != nil {
return err
}
}
for name, expected := range documentAfter {
current, err := observeInstallPath(files, name)
if err != nil {
return err
}
compareMode := baseImages[name] != nil
if !installFinalImageMatches(current, expected, compareMode) {
return fmt.Errorf(
"installation document %s changed before commit",
name,
)
}
}
for name, expected := range afterImages {
if _, mutated := mutations[name]; mutated {
continue
}
if _, document := documentAfter[name]; document {
continue
}
current, err := observeInstallPath(files, name)
if err != nil {
return err
}
if !workspacewrite.InstallImagesEqual(current, expected) {
return fmt.Errorf(
"installation path %s changed before commit",
name,
)
}
}
return nil
}()
if applyErr != nil {
return applyErr
}
journal.Committed = true
committedBytes, err := yaml.Marshal(journal)
if err != nil {
return err
}
if err := fsutil.AtomicWrite(journalPath, committedBytes, 0o600); err != nil {
return err
}
failed = false
if err := os.RemoveAll(backup); err != nil {
return &CommittedError{Err: fmt.Errorf(
"installation committed; remove rollback backup: %w", err,
)}
}
if err := os.Remove(journalPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return &CommittedError{Err: fmt.Errorf(
"installation committed; remove transaction journal: %w", err,
)}
}
logger.Info(
"installation committed",
"root", root,
"files", len(sources),
"pruned", counts.Removed,
"manifest-published", publish,
)
return nil
}
// installDocumentPlan binds the exact bytes predicted for a document
// publication to its journaled after image. Publication writes Data verbatim,
// so a concurrent comment edit cannot make published bytes differ from the
// journal.
type installDocumentPlan struct {
Data []byte
Base *workspacewrite.InstallImage
Image workspacewrite.InstallImage
}
// installDocumentPlanFor predicts the bytes and mode a document write
// publishes. It mirrors document.Service.Write without re-reading the document
// at publication time.
func installDocumentPlanFor(
root string,
name string,
defaultMode os.FileMode,
snapshot func() ([]byte, error),
merge func([]byte) ([]byte, error),
) (*installDocumentPlan, error) {
path := filepath.Join(root, name)
existing, err := os.ReadFile(path)
switch {
case err == nil:
info, statErr := os.Lstat(path)
if statErr != nil {
return nil, statErr
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("write destination %s must be a regular file", name)
}
data, err := merge(existing)
if err != nil {
return nil, err
}
return &installDocumentPlan{
Data: data,
Base: installBytesImage(existing, info.Mode().Perm()),
Image: *installBytesImage(data, info.Mode().Perm()),
}, nil
case errors.Is(err, os.ErrNotExist):
data, err := snapshot()
if err != nil {
return nil, err
}
return &installDocumentPlan{
Data: data,
Image: *installBytesImage(data, defaultMode),
}, nil
default:
return nil, err
}
}
func installFileMode(path string, defaultMode os.FileMode) (os.FileMode, error) {
info, err := os.Lstat(path)
switch {
case err == nil:
if !info.Mode().IsRegular() {
return 0, fmt.Errorf("write destination %s must be a regular file", path)
}
return info.Mode().Perm(), nil
case errors.Is(err, os.ErrNotExist):
return defaultMode, nil
default:
return 0, err
}
}
func installBytesImage(data []byte, mode os.FileMode) *workspacewrite.InstallImage {
sum := sha256.Sum256(data)
return &workspacewrite.InstallImage{
SHA256: hex.EncodeToString(sum[:]),
Mode: effectiveInstallMode(mode),
}
}
// effectiveInstallMode reports the permission bits the platform exposes after
// a chmod to mode. Windows represents regular files as writable (0666) or
// read-only (0444); Unix preserves the requested bits.
func effectiveInstallMode(mode os.FileMode) uint32 {
if runtime.GOOS == "windows" {
if mode&0o200 != 0 {
return 0o666
}
return 0o444
}
return uint32(mode.Perm())
}
func publishInstallBytes(
files *os.Root,
name string,
data []byte,
image *workspacewrite.InstallImage,
entry workspacewrite.InstallJournalEntry,
) error {
return fsutil.WriteRoot(
files,
name,
bytes.NewReader(data),
fsutil.WriteOptions{
Mode: os.FileMode(image.Mode),
Replace: true,
Check: installBeforeCheck(files, entry),
},
)
}
// installBeforeCheck guards a publication against an edit that happened after
// the journal captured the before image.
func installBeforeCheck(
files *os.Root,
entry workspacewrite.InstallJournalEntry,
) func() error {
return func() error {
current, err := observeInstallPath(files, entry.Path)
if err != nil {
return err
}
if !workspacewrite.InstallImagesEqual(current, entry.Before) {
return fmt.Errorf(
"installation output %s changed before publication",
entry.Path,
)
}
return nil
}
}
func observeInstallPath(
files *os.Root,
relative string,
) (*workspacewrite.InstallImage, error) {
info, err := files.Lstat(relative)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf(
"installation output %s is not a regular file",
relative,
)
}
file, err := files.Open(relative)
if err != nil {
return nil, err
}
hasher := sha256.New()
_, copyErr := io.Copy(hasher, file)
closeErr := file.Close()
if copyErr != nil || closeErr != nil {
return nil, errors.Join(copyErr, closeErr)
}
return &workspacewrite.InstallImage{
SHA256: hex.EncodeToString(hasher.Sum(nil)),
Mode: effectiveInstallMode(info.Mode().Perm()),
}, nil
}
func installFinalImageMatches(
current, expected *workspacewrite.InstallImage,
compareMode bool,
) bool {
if current == nil || expected == nil {
return current == nil && expected == nil
}
if current.SHA256 != expected.SHA256 {
return false
}
return !compareMode || current.Mode == expected.Mode
}
func installPathReserved(root, name string) bool {
for _, reserved := range []string{
workspacewrite.InstallJournalName,
document.LockFilename,
document.ManifestFilename,
} {
if installPathMatchesReserved(root, name, reserved) {
return true
}
}
return false
}
// installPathMatchesReserved rejects an exact reserved path always, and a case
// variant only when the target filesystem treats the two names as one entry.
func installPathMatchesReserved(root, name, reserved string) bool {
if name == reserved {
return true
}
if !strings.EqualFold(name, reserved) {
return false
}
return workspacewrite.InstallPathsAlias(root, reserved, name)
}
// rejectInstallPathAliases refuses names that the target filesystem resolves
// to the same entry. Otherwise one journal entry could overwrite and another
// could prune the same file. Distinct case-sensitive paths stay valid.
func rejectInstallPathAliases(root string, names []string) error {
aliases := make(map[string][]string, len(names))
for _, name := range names {
if !filepath.IsLocal(filepath.FromSlash(name)) ||
filepath.ToSlash(filepath.Clean(filepath.FromSlash(name))) != name {
return fmt.Errorf("installation path %q is not canonical", name)
}
key := strings.ToLower(name)
for _, prior := range aliases[key] {
if workspacewrite.InstallPathsAlias(root, prior, name) {
return fmt.Errorf(
"installation paths %q and %q alias the same filesystem entry",
prior, name,
)
}
}
aliases[key] = append(aliases[key], name)
}
return nil
}
// preserveInstallPreImage stores one complete content-addressed before image
// through a synced root-confined publish and verifies the copy. A failure here
// happens before the journal publishes, so the caller removes the unused
// backup directory.
func preserveInstallPreImage(
files *os.Root,
backupPath string,
backupRelative string,
image workspacewrite.InstallImage,
source string,
) error {
relative := filepath.Join(filepath.FromSlash(backupRelative), image.SHA256)
if info, err := files.Lstat(relative); err == nil {
if !info.Mode().IsRegular() {
return fmt.Errorf("installation pre-image %s is not a regular file", image.SHA256)
}
} else if errors.Is(err, os.ErrNotExist) {
input, err := os.Open(source)
if err != nil {
return err
}
copyErr := fsutil.WriteRoot(
files,
relative,
input,
fsutil.WriteOptions{Mode: 0o600, Replace: true},
)
closeErr := input.Close()
if copyErr != nil || closeErr != nil {
return errors.Join(copyErr, closeErr)
}
} else {
return err
}
hashes, err := DigestFile(filepath.Join(backupPath, image.SHA256), nil)
if err != nil {
return err
}
if hashes["sha256"] != image.SHA256 {
return fmt.Errorf("installation pre-image %s is incomplete", image.SHA256)
}
return nil
}
// ResolutionFingerprint digests the fields an installation receipt pins a lock
// with: the server, MCDR and package fields only. Tracked files, the manifest
// fingerprint, and Java requirement stay out. Changing these fields does not
// change installed files or invalidate the receipt.
func ResolutionFingerprint(l *document.Lock) (string, error) {
server := l.Server
server.Java = document.LockedJava{}
data, err := yaml.Marshal(struct {
Server document.LockedServer `yaml:"server"`
MCDR *document.LockedMCDR `yaml:"mcdr,omitempty"`
Packages []document.Package `yaml:"packages"`
}{server, l.MCDR, l.Packages})
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}
func installationCurrent(root string, l *document.Lock, logger *log.Logger) (bool, error) {
receiptPath := filepath.Join(root, ".lucy", "install.yaml")
raw, err := os.ReadFile(receiptPath)
if os.IsNotExist(err) {
logger.Debug("installation not current", "reason", "no install receipt")
return false, nil
}
if err != nil {
return false, err
}
var receipt installReceipt
if err := yaml.Unmarshal(raw, &receipt); err != nil {
return false, err
}
expected, err := ResolutionFingerprint(l)
if err != nil {
return false, err
}
if receipt.LockFingerprint != expected {
logger.Debug(
"installation not current",
"reason", "lock fingerprint changed",
"receipt", receipt.LockFingerprint,
"expected", expected,
)
return false, nil
}
tracked := trackedPaths(l)
for _, entry := range receipt.Entries {
if tracked[strings.ToLower(entry.Path)] {
continue
}
if err := workspacewrite.SafePath(root, entry.Path); err != nil {
return false, err
}
actual, err := DigestFile(filepath.Join(root, entry.Path), nil)
if os.IsNotExist(err) {
logger.Debug("installation not current", "reason", "owned output missing", "path", entry.Path)
return false, nil
}
if err != nil {
return false, err
}
if actual["sha256"] != entry.SHA256 {
logger.Debug("installation not current", "reason", "owned output modified", "path", entry.Path)
return false, nil
}
}
logger.Debug("installation current", "reason", "receipt matches lock and every owned output", "receipt", receiptPath)
return true, nil
}
func trackedPaths(l *document.Lock) map[string]bool {
paths := make(map[string]bool, len(l.Files))
for _, file := range l.Files {
paths[strings.ToLower(file.Path)] = true
}
return paths
}
package install
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/server"
"gopkg.in/yaml.v3"
)
func validateUnmanagedArtifacts(ctx context.Context, root string, d *document.Lock, overwrite bool, logger *log.Logger) error {
raw, err := os.ReadFile(filepath.Join(root, ".lucy", "install.yaml"))
owned := map[string]bool{}
if err == nil {
var receipt installReceipt
if err := yaml.Unmarshal(raw, &receipt); err != nil {
return err
}
for _, entry := range receipt.Entries {
owned[entry.Path] = true
}
} else if !os.IsNotExist(err) {
return err
}
seenDirectories := map[string]bool{}
lockedDestinations := lockedPackageDestinations(d)
for _, planned := range d.Packages {
if !planned.Enabled() {
continue
}
directory, folder := d.Server.Directory, packageFolder(planned.Environment, planned.Ecosystem)
if planned.Environment == server.EnvironmentMCDR {
directory = d.MCDR.Directory
}
relative := filepath.Join(directory, folder)
key := string(planned.Environment) + "/" + string(planned.Ecosystem) + "/" + relative
if seenDirectories[key] {
continue
}
seenDirectories[key] = true
entries, err := os.ReadDir(filepath.Join(root, relative))
if os.IsNotExist(err) {
continue
}
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
filename := filepath.Join(relative, entry.Name())
if owned[filepath.ToSlash(filename)] {
continue
}
if expected, ok := lockedDestinations[filepath.Clean(filename)]; ok {
if _, err := DigestFile(filepath.Join(root, filename), expected); err == nil {
logger.Debug(
"locked package already occupies its destination",
"path", filename,
)
continue
}
}
mods, err := artifact.ReadModules(ctx, filepath.Join(root, filename), planned.Ecosystem)
if err != nil {
continue
}
conflict, moduleID := unmanagedConflict(d, &planned, mods)
if conflict == nil {
continue
}
if overwrite {
destination, err := packageDestination(d, conflict)
if err == nil && filepath.Clean(filename) == filepath.Clean(destination) {
logger.Debug(
"overwriting unmanaged artifact",
"path", filename,
"module", moduleID,
)
continue
}
}
return fmt.Errorf("unmanaged artifact %s conflicts with selected module %s; adopt or move it explicitly", filename, moduleID)
}
}
logger.Debug(
"ambient workspace validated",
"root", root,
"directories", len(seenDirectories),
"packages", len(d.Packages),
)
return nil
}
// lockedPackageDestinations maps every enabled package destination to the
// digests the lock pins for it. A destination that already holds those bytes
// belongs to the locked installation even when no receipt records it, which is
// the state a workspace reaches when its receipt is removed.
func lockedPackageDestinations(d *document.Lock) map[string]map[string]string {
destinations := make(map[string]map[string]string, len(d.Packages))
for i := range d.Packages {
p := &d.Packages[i]
if !p.Enabled() {
continue
}
destination, err := packageDestination(d, p)
if err != nil {
continue
}
destinations[filepath.Clean(destination)] = p.Artifact.Hashes
}
return destinations
}
// unmanagedConflict reports the selected package whose module an unmanaged
// artifact duplicates. Environment and ecosystem must match, because one
// module identity may exist independently on the server and in MCDR.
func unmanagedConflict(
d *document.Lock,
planned *document.Package,
discovered []catalog.DescriptorModule,
) (*document.Package, string) {
for i := range d.Packages {
other := &d.Packages[i]
if other.Environment != planned.Environment || other.Ecosystem != planned.Ecosystem {
continue
}
for _, found := range discovered {
for _, selected := range other.Modules {
if artifact.ModuleProvides(found, selected.ID) || artifact.ModuleProvides(selected, found.ID) {
return other, selected.ID
}
}
}
}
return nil, ""
}
package install
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/artifactstore"
"github.com/mclucy/lucy/bootstrap"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/internal/workspacewrite"
"github.com/mclucy/lucy/javaruntime"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/upstream/routing"
"github.com/mclucy/lucy/version"
)
type (
Options struct {
Locked bool
Offline bool
AcceptBroken bool
Overwrite bool
PublishManifest bool
WithOptional bool
// MCDRVenv creates an isolated MCDReforged environment instead of
// requiring a global executable.
MCDRVenv bool
// ExpectedManifest pins the manifest fingerprint the caller selected
// packages from. Preflight checks it after replaying interrupted
// journals, so a concurrent or recovered edit fails instead of being
// overwritten.
ExpectedManifest string
// ExpectedLock pins the lock fingerprint the caller planned from.
// Preflight checks it against the recovered lock for the same reason.
ExpectedLock string
// ForceResolve resolves even when the manifest fingerprint matches the
// lock. Upgrade uses it because a lock can answer the manifest while
// still being older than the eligible versions.
ForceResolve bool
// RequirementChange records that the caller edited declared package
// requirements, so a lock whose fingerprint no longer matches is
// expected rather than stale.
RequirementChange bool
// declaredOnly records the manifest's packages without adding their
// catalogue dependencies. Adoption sets it because the workspace
// already holds a working set that resolution must not extend.
declaredOnly bool
publishLock bool
// Trace receives operation facts for the command layer to present.
Trace *Trace
// ProvisionJava runs before staging when the selected runtime misses
// the requirement. It returns the installed home when ordinary
// discovery cannot find it; selection then verifies the runtime again.
ProvisionJava func(ctx context.Context, requirement javaruntime.Requirement, check *javaruntime.Check) (string, error)
// Materialize, when non-nil, supplies artifact bytes for this
// operation instead of the configured cache and network. The returned
// path must exist; installation recomputes its digests and verifies
// them against the artifact's expected hashes before use. Injected
// bytes stay outside the operation's FetchSummary, which describes
// transfers the configured cache performed.
Materialize func(*catalog.Artifact) (artifact.MaterializedFile, error)
}
)
func (s *Service) syncInstall(ctx context.Context, root string, d *document.Manifest, old *document.Lock, opts Options, result *Result) (diagnostics []filetrack.Diagnostic, resultErr error) {
absolute, err := filepath.Abs(root)
if err != nil {
return nil, err
}
root = absolute
lease, err := artifactstore.AcquireOperation()
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, lease.Close()) }()
if err := workspacewrite.SafePath(root, ".lucy"); err != nil {
return nil, err
}
writer, err := workspacewrite.Lock(root, s.logger)
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
baseManifest, baseLock, err := preflightLocked(root, d, old, opts, true, s.logger)
if err != nil {
return nil, err
}
if err := s.registerServer(root, result); err != nil {
return nil, err
}
// Resolution and staging must start from the recovered documents. A
// publishing caller still supplies the manifest it intends to write.
manifest := baseManifest
if opts.PublishManifest {
manifest = d
}
store := s.newDownloadStore(ctx, opts, result)
resolved, err := s.resolveLock(ctx, root, manifest, baseLock, &opts, store, result)
if err != nil {
return nil, err
}
return s.stageInstall(ctx, root, manifest, resolved, store, opts, result)
}
// stageInstall materializes a resolved lock in the server directory and commits
// it with the manifest it was resolved from. Resolution paths differ in how they
// build the lock; every one of them lands here.
func (s *Service) stageInstall(
ctx context.Context,
root string,
d *document.Manifest,
resolved *document.Lock,
store *downloadStore,
opts Options,
result *Result,
) ([]filetrack.Diagnostic, error) {
if err := filetrack.ValidateOwnership(root, resolved.Files, resolved); err != nil {
return nil, err
}
check, err := s.selectJava(ctx, root, resolved, opts)
if err != nil {
return nil, err
}
result.Java = check
current, err := installationCurrent(root, resolved, s.logger)
if err != nil {
return nil, err
}
if current && !opts.PublishManifest {
s.logger.Info(
"installation already current",
"root", root,
"packages", len(resolved.Packages),
)
if err := validateUnmanagedArtifacts(ctx, root, resolved, opts.Overwrite, s.logger); err != nil {
return nil, err
}
if resolved.MCDR != nil {
if err := ensureMCDRRuntime(ctx, root, resolved, store, s.python, opts.MCDRVenv); err != nil {
return nil, err
}
}
if opts.publishLock {
if err := document.LockFile(root).Write(resolved); err != nil {
return nil, err
}
result.PublishedLock = true
return s.provisionCommitted(ctx, root, resolved, opts)
}
return s.tracker.Provision(ctx, root, resolved.Files, opts.Offline)
}
reason := "installation missing or stale"
if current {
reason = "manifest publication forces re-staging"
}
s.logger.Debug("staging installation", "root", root, "reason", reason)
opts.Trace.phaseStarted("install")
start := time.Now()
installed := false
defer func() {
if !installed {
return
}
elapsed := time.Since(start)
result.addPhase("install", elapsed)
opts.Trace.phaseDone(PhaseInfo{
Name: "install",
Elapsed: elapsed,
Count: result.Files.Created + result.Files.Replaced,
})
}()
stage, err := os.MkdirTemp(filepath.Join(root, ".lucy"), "stage-*")
if err != nil {
return nil, err
}
defer os.RemoveAll(stage)
core, err := store.materialize(&resolved.Server.Artifact)
if err != nil {
return nil, err
}
resolved.Server.Artifact.Hashes = core.Hashes
inputs := map[string]string{}
for i := range resolved.Server.Inputs {
input := &resolved.Server.Inputs[i]
if input.Embedded != "" {
continue
}
materialized, err := store.materialize(&input.Artifact)
if err != nil {
return nil, err
}
input.Artifact.Hashes = materialized.Hashes
inputs[input.Path] = materialized.Path
}
if err := bootstrap.PrepareLocked(
ctx,
stage,
resolved.Server,
core.Path,
inputs,
check.Executable(),
opts.Trace.processOutput(),
); err != nil {
return nil, err
}
s.logger.Debug(
"server tree prepared",
"stage", stage,
"core", resolved.Server.Filename,
"inputs", len(inputs),
)
if err := rejectSharedDestinations(root, resolved); err != nil {
return nil, err
}
staged := 0
overwritePaths := map[string]bool{}
for i := range resolved.Packages {
p := &resolved.Packages[i]
if !p.Enabled() {
continue
}
destination, err := packageDestination(resolved, p)
if err != nil {
return nil, err
}
if opts.Overwrite {
overwritePaths[filepath.ToSlash(destination)] = true
}
source, err := store.materialize(&p.Artifact)
if err != nil {
return nil, err
}
p.Artifact.Hashes = source.Hashes
if err := bootstrap.CopyLockedFile(source.Path, filepath.Join(stage, destination), 0o644); err != nil {
return nil, err
}
staged++
}
s.logger.Debug("packages staged", "active", staged, "recorded", len(resolved.Packages))
if resolved.MCDR != nil {
if err := preparePython(ctx, root, stage, resolved, store, s.python, opts.MCDRVenv); err != nil {
return nil, err
}
}
if err := validateUnmanagedArtifacts(ctx, root, resolved, opts.Overwrite, s.logger); err != nil {
return nil, err
}
adoptedPaths := map[string]bool{}
adoption, err := receiptAdoptsRuntime(root)
if err != nil {
return nil, err
}
if adoption {
adoptedPaths = adoptedOutputPaths(root, resolved.Server, s.logger)
}
counts, err := applyCommit(root, stage, d, resolved, opts.PublishManifest, overwritePaths, adoptedPaths, s.logger)
if err != nil {
return nil, err
}
removeSupersededPaperCore(root, resolved, s.logger)
result.Files = counts
result.PublishedManifest = opts.PublishManifest
result.PublishedLock = opts.publishLock || opts.PublishManifest
installed = true
return s.provisionCommitted(ctx, root, resolved, opts)
}
func isFloating(v version.Version) bool {
switch v {
case "", version.Stable, version.Beta, version.Any:
return true
}
return v == "latest"
}
// coreReusable reports whether a lock's server record answers the
// manifest's core request. Resolution reuses the pin when it does; a locked
// mutation treats a negative answer as a stale lock, because rebinding
// requirements must never re-target the server.
func (s *Service) coreReusable(ctx context.Context, d *document.Manifest, old *document.Lock, offline bool) (bool, error) {
request := routing.ServerProductRequest{
Minecraft: d.Server.Minecraft,
Product: d.Server.Core.Product,
ProductVersion: d.Server.Core.ProductVersion,
Repository: d.Server.Core.Repository,
FilePattern: d.Server.Core.FilePattern,
}
if old.Server.Directory != d.Server.Directory {
s.logger.Debug(
"core pin not reusable",
"reason", "server directory changed",
"locked", old.Server.Directory,
"requested", d.Server.Directory,
)
return false, nil
}
if old.Server.Minecraft != request.Minecraft || old.Server.Product != request.Product {
s.logger.Debug(
"core pin not reusable",
"reason", "pinned minecraft or product changed",
"minecraft", old.Server.Minecraft,
"requested-minecraft", request.Minecraft,
"product", old.Server.Product,
"requested-product", request.Product,
)
return false, nil
}
if !isFloating(request.ProductVersion) && old.Server.CoreVersion != request.ProductVersion {
s.logger.Debug(
"core pin not reusable",
"reason", "core version changed",
"locked", old.Server.CoreVersion,
"requested", request.ProductVersion,
)
return false, nil
}
// Dropping the repository override retargets the server at its official
// distribution, so a locked GitHub artifact no longer satisfies the request
// even though every pinned field still matches.
if request.Repository == "" && old.Server.Provider == catalog.ProviderGitHub {
s.logger.Debug(
"core pin not reusable",
"reason", "repository override dropped while pin came from a GitHub fork",
)
return false, nil
}
if request.Repository != "" {
if offline {
s.logger.Debug(
"core pin not reusable",
"reason", "repository override needs upstream confirmation while offline",
"repository", request.Repository,
)
return false, nil
}
var repository struct {
ID int64 `json:"id"`
}
if err := s.source.Metadata(ctx, "https://api.github.com/repos/"+request.Repository, &repository); err != nil {
return false, err
}
if old.Server.Provider != catalog.ProviderGitHub || old.Server.ProjectID != strconv.FormatInt(repository.ID, 10) {
s.logger.Debug(
"core pin not reusable",
"reason", "repository identity changed",
"requested-repository", request.Repository,
"resolved-id", repository.ID,
"locked-project", old.Server.ProjectID,
)
return false, nil
}
}
if request.FilePattern != "" {
matched, err := filepath.Match(request.FilePattern, old.Server.Filename)
if err != nil {
return false, err
}
if !matched {
s.logger.Debug(
"core pin not reusable",
"reason", "artifact filename no longer matches pattern",
"locked", old.Server.Filename,
"pattern", request.FilePattern,
)
return false, nil
}
}
return true, nil
}
// rejectSharedDestinations enforces the lock's installability guarantee: a
// destination two active packages both claim, or two destinations the target
// filesystem resolves to one entry, must fail before anything is written. Left
// unguarded the later copy silently replaces the earlier one, so the workspace
// diverges from a lock that still reports itself as current.
func rejectSharedDestinations(root string, resolved *document.Lock) error {
type claim struct {
path string
identity string
}
claimed := make(map[string][]claim)
for i := range resolved.Packages {
p := &resolved.Packages[i]
if !p.Enabled() {
continue
}
destination, err := packageDestination(resolved, p)
if err != nil {
return err
}
identity := p.Artifact.Provider.String() + ":" + p.Artifact.ProjectID
key := strings.ToLower(destination)
for _, prior := range claimed[key] {
if prior.path == destination {
return fmt.Errorf(
"packages %s and %s both install to %s",
prior.identity, identity, destination,
)
}
if workspacewrite.InstallPathsAlias(root, prior.path, destination) {
return fmt.Errorf(
"packages %s and %s install to aliasing destinations %s and %s",
prior.identity, identity, prior.path, destination,
)
}
}
claimed[key] = append(claimed[key], claim{
path: destination,
identity: identity,
})
}
return nil
}
// packageFolder returns the directory that serves a package's environment and
// ecosystem.
func packageFolder(environment server.Environment, ecosystem catalog.Ecosystem) string {
if environment == server.EnvironmentMCDR {
return "plugins"
}
switch ecosystem {
case catalog.EcosystemBukkit, catalog.EcosystemPaper:
return "plugins"
default:
return "mods"
}
}
// packageDestination reports where an enabled package lands inside the staged
// server tree.
func packageDestination(resolved *document.Lock, p *document.Package) (string, error) {
directory := resolved.Server.Directory
folder := packageFolder(p.Environment, p.Ecosystem)
if p.Environment == server.EnvironmentMCDR {
if resolved.MCDR == nil {
return "", fmt.Errorf("MCDR package has no wrapper runtime")
}
directory = resolved.MCDR.Directory
}
return filepath.Join(directory, folder, p.Filename), nil
}
// provisionCommitted provisions tracked files after the lock is published. A
// failure here leaves the committed installation in place, so it is marked.
func (s *Service) provisionCommitted(ctx context.Context, root string, resolved *document.Lock, opts Options) ([]filetrack.Diagnostic, error) {
diagnostics, err := s.tracker.Provision(ctx, root, resolved.Files, opts.Offline)
if err != nil {
err = &CommittedError{Err: err}
}
return diagnostics, err
}
// Package agent detects coding-agent harnesses from the process environment.
package agent
import (
"os"
"strings"
)
// Agent environment detection took reference from Cloudflare's CLI
// https://github.com/cloudflare/cf/blob/main/packages/cli/src/lib/agent-context.ts
var detectors = []func() bool{
func() bool { return exact("OPENCODE", "1") },
func() bool { return value("QWEN_CODE_SESSION_ID") != "" },
func() bool {
return exact("PI_CODING_AGENT", "true") || value("AI_AGENT") == "pi"
},
func() bool {
return exact("OMPCODE", "1") ||
value("PI_SESSION_FILE") != "" ||
value("PI_SESSION_ID") != ""
},
func() bool { return exact("DSH_SHELL", "1") },
func() bool { return exact("CURSOR_AGENT", "1") },
func() bool { return exact("CLAUDECODE", "1") },
func() bool { return value("CODEX_THREAD_ID") != "" },
func() bool { return value("AMP_CURRENT_THREAD_ID") != "" },
func() bool { return exact("GEMINI_CLI", "1") },
func() bool { return exact("AUGMENT_AGENT", "1") },
func() bool {
return exact("CRUSH", "1") || value("AI_AGENT") == "crush"
},
func() bool {
return value("AI_AGENT") == "github_copilot_vscode_agent" ||
exact("COPILOT_AGENT", "1")
},
func() bool { return value("OZ_RUN_ID") != "" },
}
// Detected reports whether any supported agent harness is present.
func Detected() bool {
for _, detect := range detectors {
if detect() {
return true
}
}
return false
}
func value(name string) string {
raw := strings.TrimSpace(os.Getenv(name))
switch strings.ToLower(raw) {
case "", "0", "false", "no", "off":
return ""
default:
return raw
}
}
func exact(name, expected string) bool {
return os.Getenv(name) == expected
}
// Package buildrepo statically probes the Gradle build layout of a Minecraft
// mod source repository. Gradle is never executed.
//
// Contract: filesystem paths are absolute, Gradle paths use the colon form
// (":" for the root project), and nothing outside the probed root is read.
// Whatever cannot be read from literals alone is reported as a Finding rather
// than guessed; a canceled context or an unreadable directory fails Probe
// instead.
package buildrepo
import (
"cmp"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
catalogpkg "github.com/mclucy/lucy/catalog"
)
type Layout struct {
Root string
Builds []GradleBuild
Findings []Finding
}
type GradleBuild struct {
Dir string
SettingsFile string
BuildFile string
Wrapper *Wrapper
Projects []Project
IncludedBuilds []string
Daemon *JavaConstraint
JavaHome string
}
type Project struct {
Path string
Dir string
BuildFile string
Ecosystems []catalogpkg.Ecosystem
Java JavaRequirements
}
type Wrapper struct {
Script, Jar, Properties string
DistributionURL, DistributionSHA256 string
Version string
}
type JavaConstraint struct {
Major int
Vendor string
}
// JavaRequirements separates compiler selection from bytecode compatibility.
// Nil fields denote absent or unresolved declarations, not unrestricted compilation.
type JavaRequirements struct {
Toolchain *JavaConstraint
Release, Source, Target *int
}
type Finding struct {
File string
Line int
Message string
}
// Probe reports literal declarations; evaluating Gradle is required to establish the active build graph.
func Probe(ctx context.Context, root string) (layout Layout, err error) {
if err := ctx.Err(); err != nil {
return Layout{}, err
}
abs, err := filepath.Abs(root)
if err != nil {
return Layout{}, fmt.Errorf("buildrepo: resolve %s: %w", root, err)
}
info, err := os.Stat(abs)
if err != nil {
return Layout{}, fmt.Errorf("buildrepo: stat %s: %w", abs, err)
}
if !info.IsDir() {
return Layout{}, fmt.Errorf("buildrepo: %s is not a directory", abs)
}
realpath, err := filepath.EvalSymlinks(abs)
if err != nil {
return Layout{}, fmt.Errorf("buildrepo: resolve symlinks of %s: %w", abs, err)
}
scoped, err := os.OpenRoot(realpath)
if err != nil {
return Layout{}, fmt.Errorf("open repository root: %w", err)
}
defer func() {
if closeErr := scoped.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("close repository root: %w", closeErr))
}
}()
p := &prober{
ctx: ctx,
root: realpath, scoped: scoped,
props: map[string]map[string]string{},
catalog: map[string]string{},
catalogs: map[string]map[string]string{},
conventions: map[string]string{},
conventionSettings: map[string]string{},
}
if err := p.discoverBuilds(); err != nil {
return Layout{}, err
}
p.indexCatalogs()
p.indexConventions()
if err := p.analyzeBuilds(); err != nil {
return Layout{}, err
}
for i := range p.builds {
sortBuild(&p.builds[i])
}
return Layout{
Root: realpath,
Builds: p.builds,
Findings: p.findings.sorted(),
}, nil
}
// skippedDirs never contain handwritten Gradle configuration.
var skippedDirs = map[string]bool{
".git": true, ".gradle": true, ".idea": true, ".kotlin": true,
"build": true, "out": true, "target": true, "node_modules": true,
}
// conventionBuildDirs hold plugin sources, never mod targets.
var conventionBuildDirs = map[string]bool{
"buildSrc": true, "build-logic": true, "buildlogic": true,
}
type prober struct {
ctx context.Context
root string
scoped *os.Root
builds []GradleBuild
findings findingList
// props caches parsed gradle.properties per directory.
props map[string]map[string]string
// catalog maps a version catalog alias to a plugin id.
catalog map[string]string
// catalogs caches parsed version catalogs per path.
catalogs map[string]map[string]string
// conventions maps a convention plugin id to its precompiled script.
conventions map[string]string
// conventionSettings holds convention plugin ids declared for settings scripts.
conventionSettings map[string]string
}
type findingList []Finding
// add records a finding once; the same observation can be reached twice, for
// example when a settings script is read during discovery and analysis.
func (f *findingList) add(file string, line int, msg string) {
for _, existing := range *f {
if existing.File == file && existing.Line == line && existing.Message == msg {
return
}
}
*f = append(*f, Finding{File: file, Line: line, Message: msg})
}
func (f findingList) sorted() []Finding {
sort.SliceStable(f, func(i, j int) bool {
a, b := &f[i], &f[j]
return cmp.Or(cmp.Compare(a.File, b.File), cmp.Compare(a.Line, b.Line), cmp.Compare(a.Message, b.Message)) < 0
})
return f
}
// contains reports whether target stays inside the root once symlinks are
// resolved. Symlinked entries escaping the root are never followed.
func (p *prober) contains(target string) bool {
realpath, err := filepath.EvalSymlinks(target)
if err != nil {
return false
}
rel, err := filepath.Rel(p.root, realpath)
if err != nil {
return false
}
return rel == "." || filepath.IsLocal(rel)
}
func (p *prober) readFile(path string) (string, error) {
relative, err := filepath.Rel(p.root, path)
if err != nil || !filepath.IsLocal(relative) {
return "", errors.New("outside repository root")
}
info, err := p.scoped.Stat(relative)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", errors.New("not a regular file")
}
data, err := p.scoped.ReadFile(relative)
return string(data), err
}
func (p *prober) readOptional(path string) (string, bool) {
if path == "" {
return "", false
}
data, err := p.readFile(path)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
p.findings.add(path, 0, "file could not be read: "+err.Error())
}
return "", false
}
return data, true
}
// firstExisting returns the first existing path among candidates.
func (p *prober) firstExisting(dir string, names ...string) string {
for _, name := range names {
path := filepath.Join(dir, name)
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() {
return path
}
}
return ""
}
func lineAt(content string, offset int) int {
return strings.Count(content[:offset], "\n") + 1
}
package buildrepo
import (
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"github.com/mclucy/lucy/catalog"
)
func (p *prober) analyzeBuilds() error {
for i := range p.builds {
build := &p.builds[i]
build.IncludedBuilds = p.includedBuildDirs(build.Dir, build.SettingsFile)
build.Wrapper = p.parseWrapper(build.Dir)
for dir := filepath.Dir(build.Dir); build.Wrapper == nil && p.contains(dir); dir = filepath.Dir(dir) {
build.Wrapper = p.parseWrapper(dir)
if dir == p.root {
break
}
}
p.applyDaemonSettings(build)
if err := p.analyzeProjects(build); err != nil {
return fmt.Errorf("buildrepo: probe %s: %w", build.Dir, err)
}
}
return nil
}
func (p *prober) applyDaemonSettings(build *GradleBuild) {
if home, ok := p.propertiesOf(build.Dir)["org.gradle.java.home"]; ok && home != "" {
build.JavaHome = home
if !filepath.IsAbs(home) {
build.JavaHome = filepath.Join(build.Dir, filepath.FromSlash(home))
}
}
criteria := p.firstExisting(build.Dir, filepath.Join("gradle", "gradle-daemon-jvm.properties"))
if criteria == "" {
return
}
content, ok := p.readOptional(criteria)
if !ok {
p.findings.add(criteria, 0, "daemon JVM criteria file could not be read")
return
}
properties, err := ParseProperties(content)
if err != nil {
p.findings.add(criteria, 0, "daemon JVM criteria could not be decoded: "+err.Error())
return
}
constraint := &JavaConstraint{
Major: parseMajor(properties["toolchainVersion"]), Vendor: properties["toolchainVendor"],
}
if constraint.Major > 0 || constraint.Vendor != "" {
build.Daemon = constraint
}
}
var majorPattern = regexp.MustCompile(`\d+`)
func parseMajor(text string) int {
match := majorPattern.FindString(text)
if match == "" {
return 0
}
value := 0
for _, digit := range match {
value = value*10 + int(digit-'0')
}
return value
}
func (p *prober) parseWrapper(dir string) *Wrapper {
properties := p.firstExisting(dir, filepath.Join("gradle", "wrapper", "gradle-wrapper.properties"))
scriptName := "gradlew"
if runtime.GOOS == "windows" {
scriptName = "gradlew.bat"
}
script := p.firstExisting(dir, scriptName)
jar := p.firstExisting(dir, filepath.Join("gradle", "wrapper", "gradle-wrapper.jar"))
if properties == "" && script == "" && jar == "" {
return nil
}
wrapper := &Wrapper{Script: script, Jar: jar, Properties: properties}
if properties == "" {
p.findings.add(script, 0, "gradlew is present without gradle/wrapper/gradle-wrapper.properties; no Gradle version is pinned")
return wrapper
}
content, ok := p.readOptional(properties)
if !ok {
p.findings.add(properties, 0, "wrapper properties could not be read; no Gradle version is pinned")
return wrapper
}
values, err := ParseProperties(content)
if err != nil {
p.findings.add(properties, 0, "wrapper properties could not be decoded: "+err.Error())
return wrapper
}
wrapper.DistributionURL = values["distributionUrl"]
wrapper.DistributionSHA256 = values["distributionSha256Sum"]
wrapper.Version = gradleVersionFromURL(wrapper.DistributionURL)
switch {
case wrapper.DistributionURL == "":
p.findings.add(properties, 0, "wrapper properties declare no distributionUrl; the pinned Gradle version is unknown")
case wrapper.Version == "":
p.findings.add(properties, 0, "wrapper distributionUrl "+wrapper.DistributionURL+" does not name a Gradle version")
}
if wrapper.DistributionURL != "" && wrapper.DistributionSHA256 == "" {
p.findings.add(properties, 0, "wrapper distribution is not pinned by a distributionSha256Sum checksum")
}
if jar == "" {
p.findings.add(properties, 0, "wrapper jar gradle/wrapper/gradle-wrapper.jar is missing; the checked in wrapper cannot run")
}
return wrapper
}
var gradleURLPattern = regexp.MustCompile(`gradle-([0-9][0-9A-Za-z.\-]*?)-(?:bin|all)\.zip`)
func gradleVersionFromURL(url string) string {
match := gradleURLPattern.FindStringSubmatch(url)
if match == nil {
return ""
}
return match[1]
}
func (p *prober) analyzeProjects(build *GradleBuild) error {
paths := p.declaredProjects(build.Dir, build.SettingsFile)
order := make([]string, 0, len(paths))
for path := range paths {
order = append(order, path)
}
slices.Sort(order)
var broadcast JavaRequirements
var broadcastSet bool
if build.BuildFile != "" {
if content, ok := p.readOptional(build.BuildFile); ok {
content = stripComments(content)
broadcast, broadcastSet = p.broadcastJava(build, content)
}
}
for _, path := range order {
dir := paths[path]
if dir == "" {
build.Projects = append(build.Projects, Project{Path: path})
continue
}
if !p.contains(dir) {
p.findings.add(build.SettingsFile, 0, "project "+path+" resolves outside the repository root and was not read")
continue
}
project := Project{
Path: path,
Dir: dir,
BuildFile: p.firstExisting(dir, "build.gradle", "build.gradle.kts"),
}
if project.BuildFile != "" {
if content, ok := p.readOptional(project.BuildFile); ok {
content = stripComments(content)
plugins := p.appliedPlugins(project.BuildFile, content, 0)
sources := p.appliedScriptSources(project.BuildFile, plugins)
project.Ecosystems = p.scriptEcosystems(project.BuildFile, plugins)
project.Java = p.javaConfigOf(project.BuildFile, content, build.Dir, sources)
p.noteGeneratedSources(&project)
p.noteConditional(project.BuildFile, content)
} else {
p.findings.add(project.BuildFile, 0, "project build script could not be read")
}
}
p.corroborate(&project)
p.inheritJava(&project, broadcast, broadcastSet)
build.Projects = append(build.Projects, project)
if err := p.ctx.Err(); err != nil {
return err
}
}
return nil
}
// broadcastJava returns the Java configuration a root script applies to every
// project through allprojects or subprojects blocks.
func (p *prober) broadcastJava(build *GradleBuild, content string) (JavaRequirements, bool) {
var req JavaRequirements
found := false
for _, name := range []string{"allprojects", "subprojects"} {
body, _, ok := blockNamed(content, name)
if !ok {
continue
}
block := p.javaConfig(build.BuildFile, body, build.Dir)
if block.Toolchain != nil && req.Toolchain == nil {
req.Toolchain = block.Toolchain
found = true
}
if block.Release != nil && req.Release == nil {
req.Release = block.Release
found = true
}
}
return req, found
}
func (p *prober) inheritJava(project *Project, broadcast JavaRequirements, broadcastSet bool) {
if broadcastSet {
if project.Java.Toolchain == nil {
project.Java.Toolchain = broadcast.Toolchain
}
if project.Java.Release == nil {
project.Java.Release = broadcast.Release
}
}
if project.Java.Toolchain == nil && project.Java.Release == nil &&
project.Java.Source == nil && project.Java.Target == nil {
p.findings.add(project.BuildFile, 0, "Java requirements of this project are unknown")
}
}
var modMetadata = []struct {
file string
eco catalog.Ecosystem
}{
{file: filepath.Join("src", "main", "resources", "fabric.mod.json"), eco: catalog.EcosystemFabric},
{file: filepath.Join("src", "main", "resources", "META-INF", "mods.toml"), eco: catalog.EcosystemForge},
{file: filepath.Join("src", "main", "resources", "META-INF", "neoforge.mods.toml"), eco: catalog.EcosystemNeoForge},
}
// corroborate fills ecosystems from loader metadata when no modding plugin was
// found. Metadata only ever confirms; it never overrides a plugin.
func (p *prober) corroborate(project *Project) {
if len(project.Ecosystems) > 0 {
return
}
var found []string
for _, entry := range modMetadata {
path := filepath.Join(project.Dir, entry.file)
if _, err := os.Stat(path); err == nil && p.contains(path) {
found = append(found, entry.file)
if !slices.Contains(project.Ecosystems, entry.eco) {
project.Ecosystems = append(project.Ecosystems, entry.eco)
}
}
}
if len(found) > 0 {
p.findings.add(project.BuildFile, 0,
"no modding plugin is applied; ecosystem taken from "+strings.Join(found, ", ")+" metadata")
}
slices.Sort(project.Ecosystems)
}
func (p *prober) noteGeneratedSources(project *Project) {
for _, name := range []string{"generated", "generated-src", "chiseledSrc"} {
path := filepath.Join(project.Dir, "src", name)
if _, err := os.Stat(path); err == nil && p.contains(path) {
p.findings.add(project.BuildFile, 0,
"generated sources under "+filepath.ToSlash(filepath.Join("src", name))+" are not inspected")
}
}
}
var conditionalPattern = regexp.MustCompile(`(?m)^\s*(if\s*\(|if\s+|when\s*\(|switch\s*\(|\}\s*else\b)`)
func (p *prober) noteConditional(file, content string) {
if match := conditionalPattern.FindStringIndex(content); match != nil {
p.findings.add(file, lineAt(content, match[0]),
"script contains conditional configuration; literal declarations do not establish which branches are active")
}
}
package buildrepo
import (
"path/filepath"
"slices"
"sort"
"strings"
"github.com/pelletier/go-toml"
)
type catalogDocument struct {
Plugins map[string]any `toml:"plugins"`
Libraries map[string]any `toml:"libraries"`
}
type catalogEntry struct {
ID string
Module string
}
// catalogAlias rewrites the separators Gradle treats as equivalent in accessor
// keys: fabric_loom, fabric.loom, and fabric-loom all read as
// libs.plugins.fabric.loom.
var catalogAlias = strings.NewReplacer("_", "-", ".", "-")
// indexCatalogs reads every libs.versions.toml reachable from the builds,
// mapping catalog aliases to plugin ids.
func (p *prober) indexCatalogs() {
for _, build := range p.builds {
for current := build.Dir; ; current = filepath.Dir(current) {
p.readCatalog(filepath.Join(current, "gradle", "libs.versions.toml"))
if current == p.root {
break
}
}
}
}
// readCatalog parses libs.versions.toml into the alias to plugin id map.
func (p *prober) readCatalog(path string) {
if _, seen := p.catalogs[path]; seen {
return
}
p.catalogs[path] = nil
content, ok := p.readOptional(path)
if !ok {
return
}
var document catalogDocument
if err := toml.Unmarshal([]byte(content), &document); err != nil {
p.findings.add(path, 0, "version catalog could not be parsed: "+err.Error())
return
}
values := map[string]string{}
for _, section := range []struct {
plugins bool
entries map[string]any
}{
{plugins: true, entries: document.Plugins},
{entries: document.Libraries},
} {
for alias, raw := range section.entries {
if id := pluginIDOfEntry(catalogEntryOf(section.plugins, raw)); id != "" {
values[catalogAlias.Replace(alias)] = id
}
}
}
p.catalogs[path] = values
for alias, id := range values {
if existing, ok := p.catalog[alias]; ok && existing != id {
continue
}
p.catalog[alias] = id
}
}
// catalogEntryOf reduces one catalog entry to the fields Lucy reads. Gradle
// accepts every entry as a table or as a short string: a plugin string is
// "id:version" and a library string is "group:name[:version]"; anything else,
// such as a nested sub-table, carries no entry.
func catalogEntryOf(plugins bool, raw any) catalogEntry {
var entry catalogEntry
switch value := raw.(type) {
case string:
if plugins {
entry.ID, _, _ = strings.Cut(value, ":")
return entry
}
group, rest, found := strings.Cut(value, ":")
if !found {
return entry
}
name, _, _ := strings.Cut(rest, ":")
entry.Module = group + ":" + name
case map[string]any:
entry.ID, _ = value["id"].(string)
entry.Module, _ = value["module"].(string)
if entry.Module == "" {
group, _ := value["group"].(string)
name, _ := value["name"].(string)
entry.Module = group + ":" + name
}
}
return entry
}
// pluginIDOfEntry derives a plugin id from a catalog entry, covering both the
// plugins table and libraries holding plugin marker modules.
func pluginIDOfEntry(entry catalogEntry) string {
if entry.ID != "" {
return entry.ID
}
group, name, found := strings.Cut(entry.Module, ":")
if !found {
return ""
}
marker, isMarker := strings.CutSuffix(name, ".gradle.plugin")
if !isMarker || marker != group {
return ""
}
return group
}
// sortBuild orders the derived collections of a build so that repeated probes
// of the same repository produce identical output.
func sortBuild(build *GradleBuild) {
sort.Slice(build.Projects, func(i, j int) bool {
return build.Projects[i].Path < build.Projects[j].Path
})
slices.Sort(build.IncludedBuilds)
for i := range build.Projects {
slices.Sort(build.Projects[i].Ecosystems)
}
}
package buildrepo
import (
"regexp"
"strings"
)
var (
callPattern = regexp.MustCompile(`\b([A-Za-z_][A-Za-z0-9_]*)\s*\(`)
identifierWord = regexp.MustCompile(`[A-Za-z_][A-Za-z0-9_]*`)
assignmentValue = regexp.MustCompile(`=[ \t]*([^\n]*)`)
)
func calls(content, name string) []int {
mask := stringMask(content)
var out []int
for _, match := range callPattern.FindAllStringSubmatchIndex(content, -1) {
if content[match[2]:match[3]] != name || inLiteral(mask, match[0]) {
continue
}
paren := strings.IndexByte(content[match[3]:], '(')
if paren < 0 {
continue
}
out = append(out, match[3]+paren)
}
return out
}
// groovyCalls returns the offsets of the first argument of every Groovy style
// call to name, which passes string literals without parentheses.
func groovyCalls(content, name string) []int {
pattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `[ \t\r\n]+['"]`)
var out []int
for _, loc := range pattern.FindAllStringIndex(content, -1) {
out = append(out, loc[1]-1)
}
return out
}
// callArgs returns the comma separated arguments of a call starting at `at`,
// which is either an opening paren or the first argument of a Groovy style
// call, together with the line of the call.
func callArgs(content string, at int) ([]string, int) {
line := lineAt(content, at)
if at < len(content) && content[at] == '(' {
body, ok := balancedParens(content, at)
if !ok || strings.TrimSpace(body) == "" {
return nil, line
}
return splitArgs(body), line
}
var args []string
for index := at; index < len(content); {
for index < len(content) && (isSpace(content[index]) || content[index] == ',') {
index++
}
if index >= len(content) {
break
}
_, width, ok := readStringLiteral(content[index:])
if !ok {
break
}
args = append(args, content[index:index+width])
index += width
}
return args, line
}
// balancedParens returns the content between the paren at start and its match.
func balancedParens(content string, start int) (string, bool) {
depth := 0
for i := start; i < len(content); i++ {
switch content[i] {
case '\'', '"':
end := skipString(content, i)
if end < 0 {
return "", false
}
i = end
case '(':
depth++
case ')':
depth--
if depth == 0 {
return content[start+1 : i], true
}
case '{':
// A named argument block never closes with the call's paren.
if !isNamedArg(content[start:i]) {
return "", false
}
}
}
return "", false
}
// isNamedArg reports whether text before a brace looks like "name =".
func isNamedArg(text string) bool {
trimmed := strings.TrimSpace(text)
if !strings.HasSuffix(trimmed, "=") {
return false
}
trimmed = strings.TrimSuffix(trimmed, "=")
if strings.TrimSpace(trimmed) == trimmed {
return false
}
return identifierWord.MatchString(trimmed)
}
func skipString(content string, start int) int {
quote := content[start]
for i := start + 1; i < len(content); i++ {
switch content[i] {
case '\\':
i++
case quote:
return i
}
}
return -1
}
// splitArgs splits a call body on top level commas.
func splitArgs(body string) []string {
var args []string
depth, start := 0, 0
for i := 0; i < len(body); i++ {
switch body[i] {
case '\'', '"':
end := skipString(body, i)
if end < 0 {
return args
}
i = end
case '(', '[', '{':
depth++
case ')', ']', '}':
depth--
case ',':
if depth == 0 {
args = append(args, strings.TrimSpace(body[start:i]))
start = i + 1
}
}
}
if last := strings.TrimSpace(body[start:]); last != "" {
args = append(args, last)
}
return args
}
func assignmentAfter(content string, paren int, name string) (string, bool) {
_, ok := balancedParens(content, paren)
if !ok {
return "", false
}
end := closingParen(content, paren)
rest := content[end+1:]
trimmed := strings.TrimLeft(rest, " \t\r\n")
if strings.HasPrefix(trimmed, "{") {
body, _, ok := block(trimmed, 0)
if !ok {
return "", false
}
for _, entry := range splitEntries(body) {
key, value, found := strings.Cut(entry, "=")
if found && strings.TrimSpace(key) == name {
return strings.TrimSpace(value), true
}
}
return "", false
}
if !strings.HasPrefix(trimmed, "."+name) {
return "", false
}
trimmed = strings.TrimLeft(trimmed[len(name)+1:], " \t\r\n")
if !strings.HasPrefix(trimmed, "=") || strings.HasPrefix(trimmed, "==") {
return "", false
}
match := assignmentValue.FindStringSubmatch(trimmed)
if match == nil {
return "", false
}
return strings.TrimSpace(match[1]), true
}
func unwrapValue(text string) (string, bool) {
text = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(text), ";"))
if value, ok := literalString(text); ok {
return value, true
}
open := strings.IndexByte(text, '(')
if open < 0 || closingParen(text, open) != len(text)-1 {
return "", false
}
callee := strings.TrimSpace(text[:open])
body, ok := balancedParens(text, open)
if !ok {
return "", false
}
arguments := splitArgs(body)
switch callee {
case "file", "project.file", "rootProject.file", "rootDir.resolve", "settingsDir.resolve":
if len(arguments) == 1 {
return literalString(arguments[0])
}
case "File", "new File":
if len(arguments) == 1 {
return literalString(arguments[0])
}
if len(arguments) == 2 && (arguments[0] == "rootDir" || arguments[0] == "settingsDir") {
return literalString(arguments[1])
}
}
return "", false
}
func closingParen(content string, start int) int {
depth := 0
for i := start; i < len(content); i++ {
switch content[i] {
case '\'', '"':
end := skipString(content, i)
if end < 0 {
return start
}
i = end
case '(':
depth++
case ')':
depth--
if depth == 0 {
return i
}
}
}
return start
}
// literalString unwraps a literal that spans all text.
func literalString(text string) (string, bool) {
text = strings.TrimSpace(text)
value, width, ok := readStringLiteral(text)
if !ok || width != len(text) {
return "", false
}
return value, true
}
// readStringLiteral reads a quoted string at the start of text and reports how
// many bytes it consumed, including both quotes.
func readStringLiteral(text string) (string, int, bool) {
if len(text) < 2 {
return "", 0, false
}
quote := text[0]
if quote != '\'' && quote != '"' {
return "", 0, false
}
for i := 1; i < len(text); i++ {
switch text[i] {
case '\\':
i++
case '\n':
return "", 0, false
case quote:
return text[1:i], i + 1, true
}
}
return "", 0, false
}
// block returns the body of the brace block whose opening brace is at offset.
func block(content string, offset int) (string, int, bool) {
if offset >= len(content) || content[offset] != '{' {
return "", offset, false
}
depth := 0
for i := offset; i < len(content); i++ {
switch content[i] {
case '\'', '"':
end := skipString(content, i)
if end < 0 {
return "", offset, false
}
i = end
case '{':
depth++
case '}':
depth--
if depth == 0 {
return content[offset+1 : i], offset + i + 1, true
}
}
}
return "", offset, false
}
// blockNamed returns the body of the first `name {` block found in content
// together with the offset of the body's first byte.
func blockNamed(content, name string) (string, int, bool) {
from := 0
for {
index := strings.Index(content[from:], name)
if index < 0 {
return "", 0, false
}
start := from + index
rest := content[start+len(name):]
trimmed := strings.TrimLeft(rest, " \t\r\n")
brace := start + len(name) + len(rest) - len(trimmed)
if strings.HasPrefix(trimmed, "{") {
body, _, ok := block(content, brace)
return body, brace + 1, ok
}
from = start + len(name)
}
}
// stripComments blanks out comments while preserving string literals and line
// structure, so commented out declarations are never mistaken for applied
// ones and reported line numbers stay correct.
func stripComments(content string) string {
out := []byte(content)
for i := 0; i < len(content); i++ {
switch content[i] {
case '\'', '"':
end := skipString(content, i)
if end < 0 {
return string(out)
}
i = end
case '/':
if i+1 >= len(content) {
continue
}
switch content[i+1] {
case '/':
for ; i < len(content) && content[i] != '\n'; i++ {
out[i] = ' '
}
case '*':
for ; i+1 < len(content) && !(content[i] == '*' && content[i+1] == '/'); i++ {
if content[i] != '\n' {
out[i] = ' '
}
}
}
}
}
return string(out)
}
// stringMask marks every byte that lies inside a string literal, so that text
// such as "build.release.platform" is never read as a Gradle declaration.
func stringMask(content string) []bool {
mask := make([]bool, len(content))
var quote byte
for i := 0; i < len(content); i++ {
switch {
case quote == 0 && (content[i] == '\'' || content[i] == '"'):
quote = content[i]
case quote != 0 && content[i] == '\\':
i++
case quote != 0 && content[i] == quote:
quote = 0
case quote != 0:
mask[i] = true
}
}
return mask
}
// inLiteral reports whether the byte at pos lies inside a string literal.
func inLiteral(mask []bool, pos int) bool {
return pos >= 0 && pos < len(mask) && mask[pos]
}
package buildrepo
import (
"fmt"
"strconv"
"strings"
"unicode/utf16"
)
// ParseProperties decodes Java-properties escapes and continued logical lines.
func ParseProperties(content string) (map[string]string, error) {
properties := make(map[string]string)
pending := ""
parse := func(line string) error {
line = strings.TrimLeft(line, " \t\f")
if line == "" || line[0] == '#' || line[0] == '!' {
return nil
}
end := 0
for end < len(line) {
if line[end] == '\\' {
end = min(end+2, len(line))
continue
}
if propertySpace(line[end]) || line[end] == '=' || line[end] == ':' {
break
}
end++
}
valueStart := end
for valueStart < len(line) && propertySpace(line[valueStart]) {
valueStart++
}
if valueStart < len(line) && (line[valueStart] == '=' || line[valueStart] == ':') {
valueStart++
}
for valueStart < len(line) && propertySpace(line[valueStart]) {
valueStart++
}
key, err := decodeProperty(line[:end])
if err != nil {
return err
}
value, err := decodeProperty(line[valueStart:])
if err != nil {
return fmt.Errorf("property %q: %w", key, err)
}
properties[key] = value
return nil
}
for raw := range strings.SplitSeq(content, "\n") {
line := strings.TrimSuffix(raw, "\r")
if pending != "" {
line = pending + strings.TrimLeft(line, " \t\f")
} else if trimmed := strings.TrimLeft(line, " \t\f"); trimmed != "" && (trimmed[0] == '#' || trimmed[0] == '!') {
// A comment line never continues, even when it ends with a
// backslash: "# e.g. C:\Tools\" must not swallow the property
// that follows it.
continue
}
trailing := 0
for i := len(line) - 1; i >= 0 && line[i] == '\\'; i-- {
trailing++
}
if trailing%2 != 0 {
pending = line[:len(line)-1]
continue
}
pending = ""
if err := parse(line); err != nil {
return nil, err
}
}
if err := parse(pending); err != nil {
return nil, err
}
return properties, nil
}
func propertySpace(char byte) bool {
return char == ' ' || char == '\t' || char == '\f'
}
func decodeProperty(text string) (string, error) {
if !strings.ContainsRune(text, '\\') {
return text, nil
}
var decoded strings.Builder
decoded.Grow(len(text))
for i := 0; i < len(text); i++ {
if text[i] != '\\' {
decoded.WriteByte(text[i])
continue
}
i++
if i == len(text) {
return "", fmt.Errorf("incomplete property escape")
}
switch text[i] {
case 't':
decoded.WriteByte('\t')
case 'r':
decoded.WriteByte('\r')
case 'n':
decoded.WriteByte('\n')
case 'f':
decoded.WriteByte('\f')
case 'u':
value, err := propertyUnicode(text[i+1:])
if err != nil {
return "", err
}
i += 4
if utf16.IsSurrogate(value) {
if !strings.HasPrefix(text[i+1:], `\u`) {
return "", fmt.Errorf("unpaired property unicode surrogate")
}
low, err := propertyUnicode(text[i+3:])
if err != nil {
return "", err
}
value = utf16.DecodeRune(value, low)
if value == '\uFFFD' {
return "", fmt.Errorf("invalid property unicode surrogate pair")
}
i += 6
}
decoded.WriteRune(value)
default:
decoded.WriteByte(text[i])
}
}
return decoded.String(), nil
}
func propertyUnicode(text string) (rune, error) {
if len(text) < 4 {
return 0, fmt.Errorf("incomplete property unicode escape")
}
value, err := strconv.ParseUint(text[:4], 16, 16)
if err != nil {
return 0, fmt.Errorf("invalid property unicode escape: %w", err)
}
return rune(value), nil
}
package buildrepo
import (
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
catalogpkg "github.com/mclucy/lucy/catalog"
)
var pluginEcosystems = []struct {
id string
ecos []catalogpkg.Ecosystem
multi bool
}{
{id: "dev.architectury.loom", multi: true},
{id: "architectury.loom", multi: true},
{id: "architectury-plugin", multi: true},
{id: "net.fabricmc.fabric-loom", ecos: []catalogpkg.Ecosystem{catalogpkg.EcosystemFabric}},
{id: "net.fabricmc.fabric-loom-remap", ecos: []catalogpkg.Ecosystem{catalogpkg.EcosystemFabric}},
{id: "fabric-loom", ecos: []catalogpkg.Ecosystem{catalogpkg.EcosystemFabric}},
{id: "net.minecraftforge.gradle", ecos: []catalogpkg.Ecosystem{catalogpkg.EcosystemForge}},
{id: "net.neoforged.moddev.legacyforge", ecos: []catalogpkg.Ecosystem{catalogpkg.EcosystemForge}},
{id: "net.neoforged.moddev", ecos: []catalogpkg.Ecosystem{catalogpkg.EcosystemNeoForge}},
{id: "net.neoforged.gradle.userdev", ecos: []catalogpkg.Ecosystem{catalogpkg.EcosystemNeoForge}},
}
var (
applyFalsePattern = regexp.MustCompile(`\bapply\s*\(?\s*false\b`)
idKeywordPattern = regexp.MustCompile(`\bid\b`)
applyPluginPattern = regexp.MustCompile(`\bapply\s*\(?\s*plugin\s*[:=]\s*`)
aliasPattern = regexp.MustCompile(`\balias\s*(?:\(\s*)?([A-Za-z0-9_.]+)\.plugins\.([A-Za-z0-9_.]+)`)
)
// pluginID reads the id declaration of a single plugins block entry. It
// returns the id and the remainder of the entry, which carries the version and
// any "apply false" modifier.
func pluginID(entry string) (string, string, bool) {
from := 0
for {
loc := idKeywordPattern.FindStringIndex(entry[from:])
if loc == nil {
return "", "", false
}
index := from + loc[1]
for index < len(entry) && isSpace(entry[index]) {
index++
}
if index < len(entry) && entry[index] == '(' {
index++
for index < len(entry) && isSpace(entry[index]) {
index++
}
}
if id, width, ok := readStringLiteral(entry[index:]); ok {
return id, entry[index+width:], true
}
from = index
}
}
func isSpace(char byte) bool {
return char == ' ' || char == '\t' || char == '\r' || char == '\n'
}
type appliedPlugin struct {
id string
line int
source string
}
func (p *prober) scriptEcosystems(file string, plugins []appliedPlugin) []catalogpkg.Ecosystem {
var out []catalogpkg.Ecosystem
for _, plugin := range plugins {
entry, ok := pluginByID(plugin.id)
if !ok {
continue
}
if entry.multi {
value, _ := p.propertyValue(filepath.Dir(file), "loom.platform")
platform := catalogpkg.Ecosystem(value)
switch platform {
case catalogpkg.EcosystemFabric, catalogpkg.EcosystemForge, catalogpkg.EcosystemNeoForge:
if !slices.Contains(out, platform) {
out = append(out, platform)
}
default:
p.findings.add(file, plugin.line, "plugin "+plugin.id+" requires platform configuration that was not resolved")
}
}
for _, eco := range entry.ecos {
if !slices.Contains(out, eco) {
out = append(out, eco)
}
}
}
slices.Sort(out)
return out
}
func pluginByID(id string) (struct {
id string
ecos []catalogpkg.Ecosystem
multi bool
}, bool,
) {
for _, entry := range pluginEcosystems {
if entry.id == id {
return entry, true
}
}
return pluginEcosystems[0], false
}
// appliedPlugins returns every plugin a script applies, resolving convention
// plugins from buildSrc and composite builds.
func (p *prober) appliedPlugins(file, content string, depth int) []appliedPlugin {
var out []appliedPlugin
seen := map[string]bool{}
mask := stringMask(content)
body, bodyStart, ok := blockNamed(content, "plugins")
if ok {
cursor := bodyStart
for _, item := range splitEntries(body) {
if item == "" || applyFalsePattern.MatchString(item) {
continue
}
rel := strings.Index(content[cursor:], item)
if rel < 0 {
continue
}
line := lineAt(content, cursor+rel)
cursor += rel + len(item)
id, rest, found := pluginID(item)
if found {
if !applyFalsePattern.MatchString(rest) {
p.appendPlugin(file, line, id, depth, seen, &out)
}
}
for _, match := range aliasPattern.FindAllStringSubmatchIndex(item, -1) {
catalog := item[match[2]:match[3]]
key := strings.ReplaceAll(item[match[4]:match[5]], ".", "-")
if catalog != "libs" {
p.findings.add(file, line, "plugin alias "+catalog+".plugins."+key+" uses a catalog that was not resolved")
continue
}
id, ok := p.catalogPluginID(key)
if !ok {
p.findings.add(file, line, "plugin alias libs.plugins."+key+" could not be resolved to a plugin id")
continue
}
p.appendPlugin(file, line, id, depth, seen, &out)
}
}
}
for _, match := range applyPluginPattern.FindAllStringIndex(content, -1) {
if inLiteral(mask, match[0]) {
continue
}
line := lineAt(content, match[0])
id, _, ok := readStringLiteral(content[match[1]:])
if !ok {
p.findings.add(file, line,
"apply plugin names a plugin class or expression instead of a literal plugin id; it was not resolved")
continue
}
p.appendPlugin(file, line, id, depth, seen, &out)
}
return out
}
// scriptSource is a build script contributing configuration to a project.
type scriptSource struct {
file string
content string
}
// appliedScriptSources returns the scripts contributing configuration to a
// project: the script that applies each plugin, plus the convention script
// that introduces the plugin. A convention script configures the project even
// when it applies no plugins of its own, so it counts regardless of whether
// any nested plugin records it as a source.
func (p *prober) appliedScriptSources(file string, plugins []appliedPlugin) []scriptSource {
seen := map[string]bool{file: true}
var out []scriptSource
add := func(path string) {
if seen[path] {
return
}
seen[path] = true
script, err := p.readFile(path)
if err != nil {
return
}
out = append(out, scriptSource{file: path, content: stripComments(script)})
}
for _, plugin := range plugins {
add(plugin.source)
if path, ok := p.conventionScript(plugin.id); ok {
add(path)
}
}
return out
}
// javaConfigOf merges the Java configuration of convention scripts and the
// project script itself; the project script wins.
func (p *prober) javaConfigOf(projectFile, content, buildDir string, sources []scriptSource) JavaRequirements {
var req JavaRequirements
for _, source := range append(sources, scriptSource{file: projectFile, content: content}) {
block := p.javaConfig(source.file, source.content, buildDir)
if block.Toolchain != nil {
req.Toolchain = block.Toolchain
}
if block.Release != nil {
req.Release = block.Release
}
if block.Source != nil {
req.Source = block.Source
}
if block.Target != nil {
req.Target = block.Target
}
}
return req
}
func (p *prober) appendPlugin(file string, line int, id string, depth int, seen map[string]bool, out *[]appliedPlugin) {
if seen[id] {
return
}
seen[id] = true
*out = append(*out, appliedPlugin{id: id, line: line, source: file})
if depth >= maxConventionDepth {
return
}
convention, ok := p.conventionScript(id)
if !ok {
return
}
content, err := p.readFile(convention)
if err != nil {
return
}
for _, nested := range p.appliedPlugins(convention, stripComments(content), depth+1) {
if seen[nested.id] {
continue
}
seen[nested.id] = true
*out = append(*out, appliedPlugin{id: nested.id, line: line, source: convention})
}
}
const maxConventionDepth = 8
func splitEntries(body string) []string {
var out []string
depth, start := 0, 0
for i := 0; i < len(body); i++ {
switch body[i] {
case '\'', '"':
end := skipString(body, i)
if end < 0 {
return out
}
i = end
case '(', '[', '{':
depth++
case ')', ']', '}':
depth--
case '\n', ';':
if depth == 0 {
out = append(out, strings.TrimSpace(body[start:i]))
start = i + 1
}
}
}
return append(out, strings.TrimSpace(body[start:]))
}
func (p *prober) indexConventions() {
for _, build := range p.builds {
for _, source := range conventionSourceDirs(build.Dir) {
p.indexConventionDir(source)
}
}
}
func (p *prober) indexConventionDir(source string) {
entries, err := os.ReadDir(source)
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
path := filepath.Join(source, entry.Name())
if !p.contains(path) {
continue
}
id := strings.TrimSuffix(strings.TrimSuffix(entry.Name(), ".gradle.kts"), ".gradle")
if id == entry.Name() || id == "" {
continue
}
if _, exists := p.conventions[id]; !exists {
p.conventions[id] = path
}
}
}
func conventionSourceDirs(dir string) []string {
var out []string
for _, base := range []string{"buildSrc", "build-logic", "buildlogic"} {
root := filepath.Join(dir, base)
for _, src := range []string{
filepath.Join(root, "src", "main", "kotlin"),
filepath.Join(root, "src", "main", "groovy"),
} {
if info, err := os.Stat(src); err == nil && info.IsDir() {
out = append(out, src)
}
}
}
return out
}
func (p *prober) conventionScript(id string) (string, bool) {
path, ok := p.conventions[id]
return path, ok
}
func (p *prober) catalogPluginID(alias string) (string, bool) {
for _, key := range []string{alias, alias + "-plugin"} {
if id, ok := p.catalog[key]; ok {
return id, true
}
}
return "", false
}
func (p *prober) propertyValue(dir, key string) (string, bool) {
for {
if value, ok := p.propertiesOf(dir)[key]; ok {
return value, true
}
if dir == p.root || slices.ContainsFunc(p.builds, func(build GradleBuild) bool { return build.Dir == dir }) {
break
}
parent := filepath.Dir(dir)
if parent == dir || !p.contains(parent) {
break
}
dir = parent
}
return "", false
}
func (p *prober) propertiesOf(dir string) map[string]string {
if cached, ok := p.props[dir]; ok {
return cached
}
path := p.firstExisting(dir, "gradle.properties")
values := map[string]string{}
if path != "" {
if content, ok := p.readOptional(path); ok {
parsed, err := ParseProperties(content)
if err != nil {
p.findings.add(path, 0, "Gradle properties could not be decoded: "+err.Error())
} else {
values = parsed
}
}
}
p.props[dir] = values
return values
}
func (p *prober) javaConfig(file, content, dir string) JavaRequirements {
var req JavaRequirements
mask := stringMask(content)
for _, match := range javaVersionPattern.FindAllStringSubmatchIndex(content, -1) {
if inLiteral(mask, match[0]) {
continue
}
line := lineAt(content, match[0])
if req.Toolchain != nil {
continue
}
major, ok := p.resolveInt(file, dir, content[match[2]:match[3]], line)
if !ok {
continue
}
req.Toolchain = &JavaConstraint{Major: major}
}
req.Release = p.releaseFlag(file, content, dir, "release")
req.Source = p.releaseFlag(file, content, dir, "sourceCompatibility")
req.Target = p.releaseFlag(file, content, dir, "targetCompatibility")
return req
}
var javaVersionPattern = regexp.MustCompile(`JavaLanguageVersion\s*\.\s*of\s*\(([^)]*)\)`)
// releaseFlag resolves a javac level flag such as options.release,
// sourceCompatibility or targetCompatibility.
func (p *prober) releaseFlag(file, content, dir, name string) *int {
pattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`)
mask := stringMask(content)
for _, loc := range pattern.FindAllStringIndex(content, -1) {
if inLiteral(mask, loc[0]) {
continue
}
expr, ok := argumentAfter(content, loc[1])
if !ok {
continue
}
if value, ok := p.resolveInt(file, dir, expr, lineAt(content, loc[0])); ok {
return &value
}
}
return nil
}
// argumentAfter reads a value written as "name = <token>", "name(...)" or a
// dotted call chain such as "name.set(...)" following the byte offset from.
func argumentAfter(content string, from int) (string, bool) {
index := from
for range 8 {
for index < len(content) && isSpace(content[index]) {
index++
}
if index >= len(content) {
return "", false
}
switch {
case content[index] == '=' && (index+1 >= len(content) || content[index+1] != '='):
return firstToken(content[index+1:]), true
case content[index] == '.':
index++
case isIdentifierByte(content[index]):
for index < len(content) && isIdentifierByte(content[index]) {
index++
}
for index < len(content) && isSpace(content[index]) {
index++
}
if index >= len(content) || content[index] != '(' {
continue
}
body, ok := balancedParens(content, index)
if !ok {
return "", false
}
return strings.TrimSpace(body), true
default:
return "", false
}
}
return "", false
}
func isIdentifierByte(char byte) bool {
return char == '_' || char == '$' ||
(char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')
}
func firstToken(text string) string {
text = strings.TrimSpace(text)
if idx := strings.IndexAny(text, " \t\r\n,;)}"); idx >= 0 {
return text[:idx]
}
return text
}
// resolveInt resolves a Java level written as a literal, a Gradle property
// reference or a catalog-free identifier.
func (p *prober) resolveInt(file, dir, expr string, line int) (int, bool) {
expr = strings.TrimSpace(expr)
// Unwrap accessors such as JavaLanguageVersion.of(x).asInt().
for range 4 {
open := strings.Index(expr, "(")
if open < 0 || !strings.HasSuffix(expr, ")") {
break
}
body, ok := balancedParens(expr, open)
if !ok {
break
}
expr = strings.TrimSpace(body)
}
expr = strings.TrimSpace(strings.TrimPrefix(expr, "project."))
expr = strings.TrimSpace(strings.TrimPrefix(expr, "rootProject."))
if value, ok := literalString(expr); ok {
expr = value
}
if major, err := strconv.Atoi(expr); err == nil {
return major, true
}
if version, ok := strings.CutPrefix(expr, "JavaVersion.VERSION_"); ok {
if major, err := strconv.Atoi(version); err == nil {
return major, true
}
}
if value, ok := p.propertyValue(dir, expr); ok {
if major, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
return major, true
}
p.findings.add(file, line, "Gradle property "+expr+"="+value+" is not a Java version")
return 0, false
}
p.findings.add(file, line, "Java version "+expr+" could not be resolved statically")
return 0, false
}
package buildrepo
import (
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"
)
func (p *prober) discoverBuilds() error {
roots := make(map[string]bool)
buildDirectories := make([]string, 0)
err := filepath.WalkDir(p.root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if err := p.ctx.Err(); err != nil {
return err
}
if entry.IsDir() {
if path != p.root && skippedDirs[entry.Name()] {
return filepath.SkipDir
}
return nil
}
switch entry.Name() {
case "settings.gradle", "settings.gradle.kts":
if p.contains(path) {
roots[filepath.Dir(path)] = true
}
case "build.gradle", "build.gradle.kts":
if p.contains(path) {
buildDirectories = append(buildDirectories, filepath.Dir(path))
}
}
return nil
})
if err != nil {
return fmt.Errorf("buildrepo: scan %s: %w", p.root, err)
}
settingsDirectories := slices.Sorted(maps.Keys(roots))
for _, dir := range buildDirectories {
covered := false
for _, settingsDir := range settingsDirectories {
relative, err := filepath.Rel(settingsDir, dir)
if err == nil && filepath.IsLocal(relative) {
covered = true
break
}
}
if !covered || filepath.Base(dir) == "buildSrc" {
roots[dir] = true
}
}
pending := slices.Collect(maps.Keys(roots))
for len(pending) > 0 {
dir := pending[0]
pending = pending[1:]
if err := p.ctx.Err(); err != nil {
return err
}
settings := p.firstExisting(dir, "settings.gradle", "settings.gradle.kts")
for _, included := range p.includedBuildDirs(dir, settings) {
if !roots[included] {
roots[included] = true
pending = append(pending, included)
}
}
}
directories := slices.Sorted(maps.Keys(roots))
for _, dir := range directories {
p.builds = append(p.builds, GradleBuild{
Dir: dir, SettingsFile: p.firstExisting(dir, "settings.gradle", "settings.gradle.kts"),
BuildFile: p.firstExisting(dir, "build.gradle", "build.gradle.kts"),
})
}
return nil
}
func (p *prober) includedBuildDirs(dir, settings string) []string {
if settings == "" {
return nil
}
content, _ := p.readOptional(settings)
content = stripComments(content)
p.noteConditional(settings, content)
included := make([]string, 0)
for _, call := range slices.Concat(calls(content, "includeBuild"), groovyCalls(content, "includeBuild")) {
arguments, line := callArgs(content, call)
if len(arguments) == 0 {
continue
}
path, ok := literalString(arguments[0])
if !ok {
p.findings.add(settings, line, "includeBuild target is not a literal path; composite build not resolved")
continue
}
target := filepath.Join(dir, filepath.FromSlash(path))
if !p.contains(target) {
if _, err := os.Lstat(target); errors.Is(err, os.ErrNotExist) {
p.findings.add(settings, line, fmt.Sprintf("includeBuild %q is not present in the checkout", path))
} else {
p.findings.add(settings, line, fmt.Sprintf("includeBuild %q resolves outside the repository root", path))
}
continue
}
if !slices.Contains(included, target) {
included = append(included, target)
}
}
return included
}
func (p *prober) declaredProjects(dir, settings string) map[string]string {
projects := map[string]string{":": dir}
if settings == "" {
return projects
}
content, _ := p.readOptional(settings)
content = stripComments(content)
mappings := make(map[string]string)
renames := make(map[string]string)
unresolved := make(map[string]bool)
for _, call := range slices.Concat(calls(content, "include"), groovyCalls(content, "include")) {
arguments, line := callArgs(content, call)
for _, argument := range arguments {
name, ok := literalString(argument)
if !ok {
p.findings.add(settings, line, "include argument is not a literal project name; project set is incomplete")
continue
}
path := gradlePathOf(name)
for path != "" && path != ":" {
if _, exists := projects[path]; !exists {
projects[path] = ""
}
path, _, _ = strings.CutLast(path, ":")
}
}
}
for _, call := range slices.Concat(calls(content, "project"), groovyCalls(content, "project")) {
arguments, line := callArgs(content, call)
if len(arguments) == 0 {
continue
}
path, ok := literalString(arguments[0])
if !ok || !strings.HasPrefix(path, ":") {
continue
}
if assignment, ok := assignmentAfter(content, call, "projectDir"); ok {
value, resolved := unwrapValue(assignment)
if !resolved {
p.findings.add(settings, line, "project "+path+" projectDir is not a literal path")
unresolved[path] = true
continue
}
mappings[path] = value
}
if assignment, ok := assignmentAfter(content, call, "name"); ok {
name, resolved := literalString(assignment)
if resolved && name != "" && !strings.Contains(name, ":") {
parent, _, _ := strings.CutLast(path, ":")
renames[path] = parent + ":" + name
} else {
p.findings.add(settings, line, "project "+path+" name is not a literal; final project path is unknown")
}
}
}
for path := range projects {
if path == ":" {
continue
}
if unresolved[path] {
projects[path] = ""
continue
}
physical := strings.ReplaceAll(strings.TrimPrefix(path, ":"), ":", "/")
if mapped, ok := mappings[path]; ok {
physical = mapped
}
if filepath.IsAbs(physical) {
projects[path] = filepath.Clean(physical)
} else {
projects[path] = filepath.Join(dir, filepath.FromSlash(physical))
}
}
for _, original := range slices.SortedFunc(maps.Keys(renames), func(a, b string) int {
return len(b) - len(a)
}) {
renamed := renames[original]
if original == renamed {
continue
}
for path, physical := range projects {
if path == original || strings.HasPrefix(path, original+":") {
target := renamed + strings.TrimPrefix(path, original)
if _, exists := projects[target]; exists {
p.findings.add(settings, 0, "project rename collides with "+target)
continue
}
delete(projects, path)
projects[target] = physical
}
}
}
return projects
}
func gradlePathOf(name string) string {
name = ":" + strings.Trim(strings.TrimSpace(name), ":")
if name == ":" || strings.Contains(name, "::") {
return ""
}
return name
}
// Package cipher provides runtime decryption for embedded secrets.
// Designed to deter casual extraction, not provide complete security.
//
// Build-time material is split into four linker fragments and optionally bound
// to a release identity (version + commit) via AEAD associated data.
package cipher
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"golang.org/x/crypto/chacha20poly1305"
)
// Linker-injected material. Set only via -X at build time; never set in source.
var (
keyA string
keyB string
ciphertextA string
ciphertextB string
// Public release identity used as AEAD associated data.
// Both empty = local unbound material; both set = tagged release.
releaseVersion string
releaseCommit string
)
// Fixed domain separator for associated data. Bump if the AD format changes.
const adDomain = "lucy-cipher-v1"
// Decode decrypts the embedded ciphertext and returns the plaintext API key.
//
// Empty embedding (all four fragments empty) returns ("", nil).
// Partial material, bad hex, wrong key length, incomplete identity, or
// ciphertext that is too short returns an error (never panics).
func Decode() (string, error) {
if keyA == "" && keyB == "" && ciphertextA == "" && ciphertextB == "" {
return "", nil
}
if keyA == "" || keyB == "" || ciphertextA == "" || ciphertextB == "" {
return "", errors.New("cipher: partial embedded material")
}
keyBytes, err := hex.DecodeString(keyA + keyB)
if err != nil {
return "", fmt.Errorf("cipher: malformed key hex: %w", err)
}
if len(keyBytes) != chacha20poly1305.KeySize {
return "", errors.New("cipher: incorrect key length")
}
data, err := hex.DecodeString(ciphertextA + ciphertextB)
if err != nil {
return "", fmt.Errorf("cipher: malformed ciphertext hex: %w", err)
}
ad, err := associatedData(releaseVersion, releaseCommit)
if err != nil {
return "", err
}
aead, err := chacha20poly1305.NewX(keyBytes)
if err != nil {
return "", err
}
if len(data) < aead.NonceSize() {
return "", errors.New("cipher: ciphertext too short")
}
nonce := data[:aead.NonceSize()]
ct := data[aead.NonceSize():]
plaintext, err := aead.Open(nil, nonce, ct, ad)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// GenerateKey returns a fresh 32-byte key from crypto/rand.
func GenerateKey() ([]byte, error) {
key := make([]byte, chacha20poly1305.KeySize)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, fmt.Errorf("cipher: generate key: %w", err)
}
return key, nil
}
// Encrypt encrypts plaintext with key, binding associated data for version and
// commit. Both identity fields must be empty (local unbound) or both nonempty
// (tagged release). Returns hex(nonce || ciphertext).
func Encrypt(key []byte, plaintext, version, commit string) (string, error) {
if len(key) != chacha20poly1305.KeySize {
return "", errors.New("cipher: incorrect key length")
}
ad, err := associatedData(version, commit)
if err != nil {
return "", err
}
aead, err := chacha20poly1305.NewX(key)
if err != nil {
return "", err
}
nonce := make([]byte, aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("cipher: read nonce: %w", err)
}
ct := aead.Seal(nil, nonce, []byte(plaintext), ad)
out := make([]byte, 0, len(nonce)+len(ct))
out = append(out, nonce...)
out = append(out, ct...)
return hex.EncodeToString(out), nil
}
// associatedData builds the canonical AD sequence:
//
// lucy-cipher-v1 \0 <version> \0 <commit>
func associatedData(version, commit string) ([]byte, error) {
vEmpty := version == ""
cEmpty := commit == ""
if vEmpty != cEmpty {
return nil, errors.New("cipher: incomplete release identity")
}
n := len(adDomain) + 1 + len(version) + 1 + len(commit)
ad := make([]byte, 0, n)
ad = append(ad, adDomain...)
ad = append(ad, 0)
ad = append(ad, version...)
ad = append(ad, 0)
ad = append(ad, commit...)
return ad, nil
}
package cli
import (
"fmt"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/spf13/cobra"
)
const (
flagWithOptionalName = "with-optional"
flagLockedName = "locked"
flagOfflineName = "offline"
)
func NewAddCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
cmd := &cobra.Command{
Use: "add",
Short: help.AddShort,
Args: cobra.MinimumNArgs(1),
Aliases: []string{"a"},
PreRunE: func(cmd *cobra.Command, args []string) error {
ecosystem, _ := cmd.Flags().GetString(FlagEcosystem)
if ecosystem != "" && !catalog.Ecosystem(ecosystem).Valid() {
return fmt.Errorf("invalid ecosystem %q", ecosystem)
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return actionAdd(cmd, inv, deps, args)
},
}
installJSON.Bind(contracts, cmd)
cmd.Flags().Bool(
flagWithOptionalName,
false,
"Also install optional upstream dependencies",
)
cmd.Flags().Bool(
flagLockedName,
false,
"Do not resolve new versions; fail when the lock cannot satisfy",
)
cmd.Flags().Bool(
flagOfflineName,
false,
"Disallow network access; fail if a needed file is not already available locally",
)
AddEcosystemFlag(cmd)
return cmd
}
func actionAdd(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
args []string,
) error {
withOptional, _ := cmd.Flags().GetBool(flagWithOptionalName)
locked, _ := cmd.Flags().GetBool(flagLockedName)
offline, _ := cmd.Flags().GetBool(flagOfflineName)
ecosystem, _ := cmd.Flags().GetString(FlagEcosystem)
opts := install.Options{
Locked: locked,
Offline: offline,
WithOptional: withOptional,
PublishManifest: false,
}
output := newInstallOutput(cmd, inv)
defer output.Close()
opts.Trace = output.Trace()
ctx, stop := CommandContext(cmd)
defer stop()
result, err := deps.Operations.Add(
ctx,
inv.Directory,
args,
catalog.Ecosystem(ecosystem),
opts,
)
if err != nil {
return output.Failure(err)
}
return output.Render(result)
}
package cli
import (
"bytes"
"cmp"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/internal/workspacewrite"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/operation"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/routing"
"github.com/spf13/cobra"
)
// bisectJSON is the shared machine-readable result of every bisect leaf: they
// all report progress through bisectOutput.
var bisectJSON JSONResult[bisectOutput]
func NewBisectCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
bisectStartCmd := &cobra.Command{
Use: "start",
Short: help.BisectStartShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionBisectStart(cmd, inv, deps, args)
},
}
bisectGoodCmd := &cobra.Command{
Use: "good",
Short: help.BisectGoodShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionBisectGood(cmd, inv, deps, args)
},
}
bisectBadCmd := &cobra.Command{
Use: "bad",
Short: help.BisectBadShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionBisectBad(cmd, inv, deps, args)
},
}
bisectStatusCmd := &cobra.Command{
Use: "status",
Short: help.BisectStatusShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionBisectStatus(cmd, inv, deps, args)
},
}
bisectResetCmd := &cobra.Command{
Use: "reset",
Short: help.BisectResetShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionBisectReset(cmd, inv, deps, args)
},
}
bisectCmd := &cobra.Command{
Use: "bisect",
Short: help.BisectShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
MarkHelpOnly(bisectCmd)
bisectJSON.Bind(contracts, bisectStartCmd)
bisectJSON.Bind(contracts, bisectGoodCmd)
bisectJSON.Bind(contracts, bisectBadCmd)
bisectJSON.Bind(contracts, bisectStatusCmd)
bisectJSON.Bind(contracts, bisectResetCmd)
bisectCmd.AddCommand(
bisectStartCmd,
bisectGoodCmd,
bisectBadCmd,
bisectStatusCmd,
bisectResetCmd,
)
return bisectCmd
}
// bisectMod is one manifest requirement the session toggles. Reference is the
// manifest's own "provider:project" key and Ecosystem is the ecosystem it is
// declared under; together they identify the entry unambiguously.
type bisectMod struct {
Environment server.Environment `json:"environment"`
Ecosystem catalog.Ecosystem `json:"ecosystem"`
Reference string `json:"reference"`
Version string `json:"version,omitempty"`
}
const (
bisectFilename = "bisect.json"
bisectLockFilename = "bisect.lock"
)
// bisectPhase is the durable phase of a session. A session starts active and
// ends in exactly one terminal phase; only reset returns the workspace to
// having no session.
type bisectPhase string
const (
bisectPhaseActive bisectPhase = "active"
bisectPhaseFound bisectPhase = "found"
bisectPhaseExhausted bisectPhase = "exhausted"
)
// bisectStep names the leaf that owns a pending transition. A retry must run
// the same leaf, so the step is recorded instead of inferred from the range.
type bisectStep string
const (
bisectStepStart bisectStep = "start"
bisectStepGood bisectStep = "good"
bisectStepBad bisectStep = "bad"
bisectStepReset bisectStep = "reset"
)
// bisectPending is the durable intent of a transition whose manifest write
// and installation sync have not both completed. The target phase and range
// live on bisectState; this record names the command to rerun and the manifest
// digest the transition intended to publish. The midpoint it marked follows
// from the target range: good advanced Left past it, bad moved Right onto it.
type bisectPending struct {
Step bisectStep `json:"step"`
Digest string `json:"digest,omitempty"`
Resolution string `json:"resolution,omitempty"`
}
// bisectState is the persisted session. Original is the manifest as it stood
// before the first toggle and is retained until reset has restored and synced
// it, so an interrupted session never loses the manifest it must return to.
type bisectState struct {
Phase bisectPhase `json:"phase"`
Mods []bisectMod `json:"mods"`
Original *document.Manifest `json:"original"`
Left int `json:"left"`
Right int `json:"right"`
// Applied is the digest of the manifest the last successful transition
// left in the workspace. Verdicts and reset refuse to overwrite a manifest
// that no longer matches it, the pending target, or the original.
Applied string `json:"applied,omitempty"`
Pending *bisectPending `json:"pending,omitempty"`
}
type bisectOutput struct {
Message string `json:"message"`
Phase bisectPhase `json:"phase,omitempty"`
Pending bisectStep `json:"pending,omitempty"`
Complete bool `json:"complete"`
Found *bisectMod `json:"found,omitempty"`
State *bisectView `json:"state,omitempty"`
Enabled int `json:"enabled"`
Disabled int `json:"disabled"`
Restored int `json:"restored"`
Warnings []string `json:"warnings,omitempty"`
}
type bisectView struct {
Total int `json:"total"`
Left int `json:"left"`
Right int `json:"right"`
Midpoint int `json:"midpoint,omitempty"`
Candidate *bisectMod `json:"candidate,omitempty"`
Remaining int `json:"remaining"`
}
func bisectFilePath(workDir string) string {
return filepath.Join(workDir, bisectFilename)
}
func bisectLockPath(workDir string) string {
return filepath.Join(workDir, ".lucy", bisectLockFilename)
}
// bisectStatePresent checks for a session without creating workspace state.
func bisectStatePresent(workDir string) (bool, error) {
_, err := os.Stat(bisectFilePath(workDir))
if err == nil {
return true, nil
}
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("inspect bisect state: %w", err)
}
// requireBisectSession rejects a command that cannot have a session before the
// lock creates .lucy in a directory that is not a bisect workspace.
func requireBisectSession(workDir string) error {
present, err := bisectStatePresent(workDir)
if err != nil {
return err
}
if !present {
return fmt.Errorf("no bisect session found, run `lucy bisect start` first")
}
return nil
}
// requireBisectStartWorkspace rejects start before locking when there is
// neither a session to resume nor a manifest to capture. A lock-only
// workspace cannot start a bisect session because Original would be missing.
func requireBisectStartWorkspace(workDir string) error {
present, err := bisectStatePresent(workDir)
if err != nil {
return err
}
if present {
return nil
}
manifest := document.ManifestFile(workDir).Path()
if _, err := os.Stat(manifest); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read manifest: %w", err)
}
return fmt.Errorf("inspect manifest: %w", err)
}
return nil
}
// lockBisectSession serializes every session transition across processes. It
// waits instead of failing so a concurrent start, verdict, status, or reset
// observes the previous command's committed state, and it stops waiting when
// the command context ends so a canceled command cannot later mutate the
// session. require runs before .lucy is created, so a command that cannot have
// a session or manifest fails without leaving a workspace marker. It is
// separate from the workspace writer lock because installation sync takes that
// lock itself.
func lockBisectSession(
ctx context.Context,
workDir string,
require func(string) error,
) (*fsutil.FileLock, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := require(workDir); err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
relative := filepath.Join(".lucy", bisectLockFilename)
if err := workspacewrite.SafePath(workDir, relative); err != nil {
return nil, err
}
if err := fsutil.MkdirAllPrivate(filepath.Dir(bisectLockPath(workDir))); err != nil {
return nil, fmt.Errorf("create workspace state directory: %w", err)
}
if err := workspacewrite.SafePath(workDir, relative); err != nil {
return nil, err
}
for {
if err := ctx.Err(); err != nil {
return nil, err
}
lock, err := fsutil.TryLockFile(bisectLockPath(workDir))
if err == nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, errors.Join(ctxErr, lock.Close())
}
return lock, nil
}
if !errors.Is(err, fsutil.ErrLocked) {
return nil, fmt.Errorf("lock bisect session: %w", err)
}
timer := time.NewTimer(50 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return nil, fmt.Errorf("wait for bisect session lock: %w", ctx.Err())
case <-timer.C:
}
}
}
// readBisectState returns the session, or nil when this workspace has none. A
// corrupt state is an error rather than an empty session: replacing it would
// lose the manifest that reset must restore.
func readBisectState(workDir string) (*bisectState, error) {
data, err := os.ReadFile(bisectFilePath(workDir))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read bisect state: %w", err)
}
var session bisectState
if err := json.Unmarshal(data, &session); err != nil {
return nil, fmt.Errorf(
"failed to parse bisect state: %w; the captured original, if present, remains in %s; extract its original value to restore lucy.yaml manually, then delete %s",
err,
bisectFilename,
bisectFilename,
)
}
if err := validateBisectState(&session); err != nil {
return nil, fmt.Errorf(
"%w; the captured original, if present, remains in %s; extract its original value to restore lucy.yaml manually, then delete %s",
err,
bisectFilename,
bisectFilename,
)
}
return &session, nil
}
// loadBisectSession reads the session and reports an actionable error when the
// workspace has none.
func loadBisectSession(workDir string) (*bisectState, error) {
session, err := readBisectState(workDir)
if err != nil {
return nil, err
}
if session == nil {
return nil, fmt.Errorf("no bisect session found, run `lucy bisect start` first")
}
return session, nil
}
func bisectStateBytes(session *bisectState) ([]byte, error) {
data, err := json.Marshal(session, jsontext.Multiline(true), jsontext.WithIndent(" "))
if err != nil {
return nil, fmt.Errorf("failed to serialize bisect state: %w", err)
}
return data, nil
}
func writeBisectState(workDir string, session *bisectState) error {
if err := validateBisectState(session); err != nil {
return err
}
data, err := bisectStateBytes(session)
if err != nil {
return err
}
if err := fsutil.AtomicWrite(bisectFilePath(workDir), data, 0o600); err != nil {
return fmt.Errorf("failed to write bisect state: %w", err)
}
return nil
}
// createBisectState publishes the first state of a session without replacing
// one that another process may have created since the caller checked.
func createBisectState(workDir string, session *bisectState) (resultErr error) {
if err := validateBisectState(session); err != nil {
return err
}
data, err := bisectStateBytes(session)
if err != nil {
return err
}
root, err := os.OpenRoot(workDir)
if err != nil {
return fmt.Errorf("open workspace: %w", err)
}
defer func() { resultErr = errors.Join(resultErr, root.Close()) }()
if err := fsutil.WriteRoot(
root,
bisectFilename,
bytes.NewReader(data),
fsutil.WriteOptions{Mode: 0o600},
); err != nil {
return fmt.Errorf("failed to create bisect state: %w", err)
}
return nil
}
func deleteBisectState(workDir string) error {
if err := os.Remove(bisectFilePath(workDir)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove bisect state: %w", err)
}
return nil
}
func validateBisectState(session *bisectState) error {
if session == nil {
return fmt.Errorf("invalid bisect state: empty state")
}
if len(session.Mods) == 0 {
return fmt.Errorf("invalid bisect state: no packages")
}
if session.Original == nil {
return fmt.Errorf("invalid bisect state: no captured manifest")
}
if session.Pending == nil ||
(session.Pending.Step != bisectStepStart &&
session.Pending.Step != bisectStepReset) {
if session.Applied == "" {
return fmt.Errorf("invalid bisect state: no committed manifest digest")
}
} else if session.Pending.Step == bisectStepStart && session.Applied != "" {
return fmt.Errorf("invalid bisect state: interrupted start already has a committed manifest digest")
}
switch session.Phase {
case bisectPhaseActive:
if session.Left < 0 || session.Right >= len(session.Mods) || session.Left > session.Right {
return fmt.Errorf(
"invalid bisect state: active range [%d, %d] outside %d packages",
session.Left,
session.Right,
len(session.Mods),
)
}
case bisectPhaseFound:
if session.Left != session.Right ||
session.Left < 0 ||
session.Left >= len(session.Mods) {
return fmt.Errorf(
"invalid bisect state: found range [%d, %d] is not one package",
session.Left,
session.Right,
)
}
case bisectPhaseExhausted:
if session.Right < 0 ||
session.Right >= len(session.Mods) ||
session.Left != session.Right+1 {
return fmt.Errorf(
"invalid bisect state: exhausted range [%d, %d] is inconsistent",
session.Left,
session.Right,
)
}
default:
return fmt.Errorf("invalid bisect state: unknown phase %q", session.Phase)
}
return validateBisectPending(session)
}
func validateBisectPending(session *bisectState) error {
pending := session.Pending
if pending == nil {
return nil
}
if pending.Digest == "" || pending.Resolution == "" {
return fmt.Errorf("invalid bisect state: pending transition records no manifest digest and resolution fingerprint")
}
switch pending.Step {
case bisectStepStart:
if session.Phase != bisectPhaseActive ||
session.Left != 0 ||
session.Right != len(session.Mods)-1 {
return fmt.Errorf("invalid bisect state: interrupted start does not own the full range")
}
case bisectStepGood:
if session.Left <= 0 ||
(session.Phase != bisectPhaseActive && session.Phase != bisectPhaseExhausted) {
return fmt.Errorf("invalid bisect state: interrupted good does not match its range or phase")
}
case bisectStepBad:
if session.Right < 0 ||
(session.Phase != bisectPhaseActive && session.Phase != bisectPhaseFound) {
return fmt.Errorf("invalid bisect state: interrupted bad does not match its range or phase")
}
case bisectStepReset:
// Reset can follow any phase; the pending digest names the manifest
// the failed reset left in place.
default:
return fmt.Errorf("invalid bisect state: unknown pending step %q", pending.Step)
}
return nil
}
// errBisectManifestChanged refuses to proceed when the manifest no longer
// matches any state the session itself published. Restoring the original would
// otherwise discard an unrelated edit without leaving a trace.
var errBisectManifestChanged = errors.New(
"lucy.yaml changed outside the bisect session; refusing to overwrite it. " +
"Undo that change and retry, or recover the captured original from " +
"bisect.json (mode 0600) and delete bisect.json to abandon the session",
)
var errBisectManifestChangedDuringSync = errors.New(
"lucy.yaml changed while the installation synced; the bisect transition " +
"remains pending. Restore lucy.yaml and rerun the command, or recover " +
"the captured original from bisect.json (mode 0600)",
)
// bisectManifestResolutionFingerprint is the fingerprint installation uses to
// decide whether a lock still answers the manifest.
func bisectManifestResolutionFingerprint(doc *document.Manifest) (string, error) {
fingerprint, err := doc.Fingerprint()
if err != nil {
return "", fmt.Errorf("fingerprint manifest: %w", err)
}
return fingerprint, nil
}
// bisectManifestDigest covers every persisted manifest field, including name
// metadata that resolution intentionally ignores. reset and verdicts use it to
// distinguish a manifest the session published from one edited outside it.
func bisectManifestDigest(doc *document.Manifest) (string, error) {
if err := doc.Normalize(); err != nil {
return "", fmt.Errorf("normalize manifest: %w", err)
}
data, err := json.Marshal(doc, json.Deterministic(true))
if err != nil {
return "", fmt.Errorf("digest manifest: %w", err)
}
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// withBisectWorkspace replays interrupted workspace journals under the shared
// writer lock and runs action while that lock is held. The caller must hold
// the bisect session lock; action must not call installation sync, which takes
// the same writer lock itself.
func withBisectWorkspace(
ctx context.Context,
workDir string,
logger *log.Logger,
action func() error,
) (resultErr error) {
if err := ctx.Err(); err != nil {
return err
}
writer, err := workspacewrite.Lock(workDir, logger)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
if err := ctx.Err(); err != nil {
return err
}
if err := filetrack.RecoverLocked(workDir, logger); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
return action()
}
type bisectManifestRelation int
const (
bisectManifestForeign bisectManifestRelation = iota
bisectManifestOwned
// bisectManifestRenamed means the manifest matches the pending target in
// every field the resolution fingerprint covers, but differs in a field it
// intentionally ignores, currently the server name. The caller adopts that
// name instead of rejecting a rename that cannot affect installation.
bisectManifestRenamed
)
// classifyBisectManifest reports how doc relates to the states this session
// published. Without a pending transition, a changed manifest is foreign even
// when only its name differs; that edit happened after a committed step and
// reset must not discard it silently.
func classifyBisectManifest(
session *bisectState,
doc *document.Manifest,
) (bisectManifestRelation, error) {
digest, err := bisectManifestDigest(doc)
if err != nil {
return bisectManifestForeign, err
}
if digest == session.Applied {
return bisectManifestOwned, nil
}
if session.Pending != nil && digest == session.Pending.Digest {
return bisectManifestOwned, nil
}
original, err := bisectManifestDigest(session.Original)
if err != nil {
return bisectManifestForeign, err
}
if digest == original {
return bisectManifestOwned, nil
}
if session.Pending == nil || session.Pending.Resolution == "" {
return bisectManifestForeign, nil
}
resolution, err := bisectManifestResolutionFingerprint(doc)
if err != nil {
return bisectManifestForeign, err
}
if resolution == session.Pending.Resolution {
return bisectManifestRenamed, nil
}
return bisectManifestForeign, nil
}
func adoptBisectRename(session *bisectState, doc *document.Manifest) {
session.Original.Name = doc.Name
}
// toggleBisectRange enables requirements through mid and disables the rest
// after them without writing anything. It returns both the ownership digest
// and the resolution fingerprint machinery pins during sync.
func toggleBisectRange(
doc *document.Manifest,
mods []bisectMod,
mid int,
) (
enabled, disabled, matched int,
digest, resolution string,
err error,
) {
for i, mod := range mods {
if setRequirementEnabled(doc, mod, i <= mid) {
matched++
if i <= mid {
enabled++
} else {
disabled++
}
}
}
digest, err = bisectManifestDigest(doc)
if err != nil {
return 0, 0, 0, "", "", err
}
resolution, err = bisectManifestResolutionFingerprint(doc)
if err != nil {
return 0, 0, 0, "", "", err
}
return enabled, disabled, matched, digest, resolution, nil
}
// prepareBisectRangeLocked writes the target digest to the pending transition
// before changing the manifest, then writes the range. The caller holds the
// workspace writer lock after journal recovery.
func prepareBisectRangeLocked(
ctx context.Context,
workDir string,
session *bisectState,
) (enabled, disabled int, digest, resolution string, err error) {
if err := ctx.Err(); err != nil {
return 0, 0, "", "", err
}
if session.Pending == nil {
return 0, 0, "", "", fmt.Errorf("invalid bisect state: no pending transition")
}
doc, err := document.ManifestFile(workDir).Read()
if err != nil {
return 0, 0, "", "", fmt.Errorf("read manifest: %w", err)
}
relation, err := classifyBisectManifest(session, doc)
if err != nil {
return 0, 0, "", "", err
}
switch relation {
case bisectManifestForeign:
return 0, 0, "", "", errBisectManifestChanged
case bisectManifestRenamed:
adoptBisectRename(session, doc)
}
mid := (session.Left + session.Right) / 2
var matched int
enabled, disabled, matched, digest, resolution, err = toggleBisectRange(doc, session.Mods, mid)
if err != nil {
return 0, 0, "", "", err
}
session.Pending.Digest = digest
session.Pending.Resolution = resolution
if err := ctx.Err(); err != nil {
return 0, 0, "", "", err
}
if err := writeBisectState(workDir, session); err != nil {
return 0, 0, "", "", err
}
if matched > 0 {
if err := ctx.Err(); err != nil {
return 0, 0, "", "", err
}
if err := document.ManifestFile(workDir).Write(doc); err != nil {
return 0, 0, "", "", fmt.Errorf("write manifest: %w", err)
}
}
return enabled, disabled, digest, resolution, nil
}
// prepareBisectRange stages the pending range. The caller syncs afterwards,
// once the workspace writer lock is released, because installation takes that
// lock itself.
func prepareBisectRange(
ctx context.Context,
workDir string,
logger *log.Logger,
session *bisectState,
) (enabled, disabled int, digest, resolution string, err error) {
err = withBisectWorkspace(ctx, workDir, logger, func() error {
var actionErr error
enabled, disabled, digest, resolution, actionErr = prepareBisectRangeLocked(ctx, workDir, session)
return actionErr
})
return enabled, disabled, digest, resolution, err
}
// setRequirementEnabled flips one manifest requirement and reports whether
// the entry was present.
func setRequirementEnabled(doc *document.Manifest, mod bisectMod, enabled bool) bool {
var packages map[string]document.Requirement
switch mod.Environment {
case server.EnvironmentServer:
if doc.Server.Packages == nil {
return false
}
packages = doc.Server.Packages[mod.Ecosystem]
case server.EnvironmentMCDR:
if doc.MCDR == nil {
return false
}
packages = doc.MCDR.Packages
default:
return false
}
requirement, ok := packages[mod.Reference]
if !ok {
return false
}
value := enabled
requirement.Enabled = &value
packages[mod.Reference] = requirement
return true
}
// writeBisectOriginal restores the manifest captured before the first toggle.
// It records step and the target digest on the pending transition before
// writing, then writes the original. The caller syncs afterwards; a retry
// after a failed sync must run that sync even when the manifest already
// matches.
func writeBisectOriginal(
ctx context.Context,
workDir string,
logger *log.Logger,
session *bisectState,
step bisectStep,
) (digest, resolution string, err error) {
if err := ctx.Err(); err != nil {
return "", "", err
}
err = withBisectWorkspace(ctx, workDir, logger, func() error {
current, err := document.ManifestFile(workDir).Read()
if err != nil {
return fmt.Errorf("read manifest: %w", err)
}
relation, err := classifyBisectManifest(session, current)
if err != nil {
return err
}
switch relation {
case bisectManifestForeign:
return errBisectManifestChanged
case bisectManifestRenamed:
adoptBisectRename(session, current)
}
originalDigest, err := bisectManifestDigest(session.Original)
if err != nil {
return err
}
originalResolution, err := bisectManifestResolutionFingerprint(session.Original)
if err != nil {
return err
}
digest = originalDigest
resolution = originalResolution
switch {
case session.Pending == nil:
session.Pending = &bisectPending{
Step: step,
Digest: originalDigest,
Resolution: originalResolution,
}
case step == bisectStepReset:
// Reset supersedes an interrupted leaf. Keep that leaf's target
// digest until the original is written, so a retry can still
// recognize the manifest the failed reset left in place.
session.Pending.Step = bisectStepReset
if session.Pending.Digest == "" {
session.Pending.Digest = originalDigest
}
if session.Pending.Resolution == "" {
session.Pending.Resolution = originalResolution
}
default:
session.Pending.Step = step
session.Pending.Digest = originalDigest
session.Pending.Resolution = originalResolution
}
if err := ctx.Err(); err != nil {
return err
}
if err := writeBisectState(workDir, session); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if err := document.ManifestFile(workDir).Write(session.Original); err != nil {
return fmt.Errorf("restore manifest: %w", err)
}
return nil
})
return digest, resolution, err
}
// syncWarnings collects the facts one sync produced so a bisect result can
// carry them. Diagnostics and warnings stay separate from stderr output, so a
// JSON run never emits a second document beside the result.
func syncWarnings(result *install.Result) []string {
if result == nil {
return nil
}
warnings := make([]string, 0, len(result.Diagnostics)+len(result.Warnings))
for _, diagnostic := range result.Diagnostics {
warnings = append(
warnings,
fmt.Sprintf("%s: %s", diagnostic.Path, diagnostic.Message),
)
}
for _, warning := range result.Warnings {
if warning != nil {
warnings = append(warnings, warning.Error())
}
}
return warnings
}
// countRequirements counts every declared requirement entry across the
// server and MCDR manifests, so the reported restore covers every runtime
// rather than one loader map.
func countRequirements(doc *document.Manifest) int {
total := 0
for _, packages := range doc.Server.Packages {
total += len(packages)
}
if doc.MCDR != nil {
total += len(doc.MCDR.Packages)
}
return total
}
func currentBisectView(session *bisectState) *bisectView {
view := &bisectView{
Total: len(session.Mods),
Left: session.Left,
Right: session.Right,
}
if session.Phase != bisectPhaseActive {
return view
}
view.Remaining = session.Right - session.Left + 1
mid := (session.Left + session.Right) / 2
view.Midpoint = mid
view.Candidate = &session.Mods[mid]
return view
}
func outputBisect(
cmd *cobra.Command,
inv *Invocation,
view bisectOutput,
) error {
output := NewOperationOutput(cmd, inv)
if cmd.Name() == "status" && cmd.Parent() != nil && cmd.Parent().Name() == "bisect" {
output = NewQueryOutput(cmd, inv)
}
if output.JSON() {
return bisectJSON.Write(output, view)
}
if _, err := fmt.Fprintln(output.Human(), formatBisectOutput(view)); err != nil {
return err
}
for _, warning := range view.Warnings {
output.Warn(errors.New(warning))
}
return nil
}
func formatBisectOutput(output bisectOutput) string {
var builder strings.Builder
builder.WriteString("Bisect: ")
builder.WriteString(output.Message)
builder.WriteByte('\n')
if output.State != nil {
builder.WriteString(
fmt.Sprintf(
"Range: [%d, %d]\n",
output.State.Left,
output.State.Right,
),
)
builder.WriteString(
fmt.Sprintf(
"Remaining: %d of %d\n",
output.State.Remaining,
output.State.Total,
),
)
if output.State.Candidate != nil {
builder.WriteString(
fmt.Sprintf(
"Candidate: %s (midpoint %d)\n",
bisectModLabel(*output.State.Candidate),
output.State.Midpoint,
),
)
}
}
if output.Found != nil {
builder.WriteString("Bad package: ")
builder.WriteString(bisectModLabel(*output.Found))
builder.WriteByte('\n')
}
if output.Enabled > 0 || output.Disabled > 0 || output.Restored > 0 {
builder.WriteString(
fmt.Sprintf(
"Changed: enabled %d, disabled %d, restored %d\n",
output.Enabled,
output.Disabled,
output.Restored,
),
)
}
if output.Pending == bisectStepReset {
builder.WriteString("Pending: rerun `lucy bisect reset` to complete the interrupted reset.\n")
} else if output.Pending != "" {
builder.WriteString(
fmt.Sprintf(
"Pending: rerun `lucy bisect %s` to complete the interrupted transition or `lucy bisect reset` to abandon the session.\n",
output.Pending,
),
)
}
if !output.Complete && output.Pending == "" && output.State != nil && output.State.Candidate != nil {
builder.WriteString("Start the server, test it, then run `lucy bisect good` or `lucy bisect bad`.")
}
return strings.TrimRight(builder.String(), "\n")
}
func bisectModLabel(mod bisectMod) string {
if mod.Version == "" {
return mod.Reference
}
return mod.Reference + "@" + mod.Version
}
// bisectCandidates returns the manifest requirements a session may toggle,
// ordered so a package always follows the packages it depends on. doc is the
// recovered snapshot the session records as its original; dependency loading
// may take long enough that start must revalidate the file before publishing.
func bisectCandidates(
ctx context.Context,
lookup func(context.Context, upstream.ArtifactFingerprint) routing.ArtifactLookup,
workDir string,
doc *document.Manifest,
output *Output,
) ([]bisectMod, []string, error) {
graph, _, _, err := LoadDependencyData(ctx, workDir, false, lookup, output)
if err != nil {
return nil, nil, err
}
var mods []bisectMod
seen := make(map[string]bool)
for _, root := range bisectOrderedArtifacts(graph) {
for _, reference := range bisectNodeReferences(doc, root) {
if !requirementEnabled(
doc,
root.Environment,
root.Ecosystem,
reference,
) {
continue
}
mod := bisectMod{
Environment: root.Environment,
Ecosystem: root.Ecosystem,
Reference: reference,
Version: root.Version,
}
key := string(mod.Environment) + "|" +
string(mod.Ecosystem) + "|" + mod.Reference
if seen[key] {
continue
}
seen[key] = true
mods = append(mods, mod)
}
}
return mods, output.Warnings(), nil
}
// bisectOrderedArtifacts returns every artifact node in dependency-first
// order. Root selection is not enough: in a live observation, a directly
// declared requirement can also be another package's dependency.
//
// The order is computed on the artifact graph. Sorting the mixed graph and
// dropping modules afterwards is not enough: an artifact only becomes ready
// after every module it ships, while a dependent only waits for the module it
// needs, so a package shipping several modules can follow its own dependent.
// Collapsing each module onto its artifact first keeps the edge from that
// dependent to the package that provides the required module.
func bisectOrderedArtifacts(graph *DependencyGraph) []*GraphNode {
artifacts := make([]*GraphNode, 0, len(graph.Nodes))
for _, node := range graph.Nodes {
if !node.Module {
artifacts = append(artifacts, node)
}
}
compare := func(a, b *GraphNode) int {
if c := cmp.Compare(a.ID, b.ID); c != 0 {
return c
}
return cmp.Compare(a.key(), b.key())
}
byID := func() []*GraphNode {
slices.SortFunc(artifacts, compare)
return artifacts
}
// A module is represented by the artifact that ships it.
owner := make(map[string]*GraphNode, len(graph.Nodes))
for _, node := range graph.Nodes {
if !node.Module {
owner[node.key()] = node
continue
}
artifact, ok := graph.Nodes[nodeKey(
node.Environment,
node.Ecosystem,
node.Artifact,
)]
if ok {
owner[node.key()] = artifact
}
}
// Lift edges onto owning artifacts, dropping the tree-only
// artifact-to-own-module self edges.
dependencies := make(map[string]map[string]bool, len(artifacts))
dependents := make(map[string][]*GraphNode, len(artifacts))
for _, node := range graph.Nodes {
dependent := owner[node.key()]
if dependent == nil {
continue
}
dependentKey := dependent.key()
for _, child := range node.Children {
dependency := owner[child.key()]
if dependency == nil || dependency == dependent {
continue
}
dependencyKey := dependency.key()
if dependencies[dependentKey] == nil {
dependencies[dependentKey] = make(map[string]bool)
}
if dependencies[dependentKey][dependencyKey] {
continue
}
dependencies[dependentKey][dependencyKey] = true
dependents[dependencyKey] = append(dependents[dependencyKey], dependent)
}
}
remaining := make(map[string]int, len(artifacts))
queue := make([]*GraphNode, 0, len(artifacts))
for _, artifact := range artifacts {
key := artifact.key()
remaining[key] = len(dependencies[key])
if remaining[key] == 0 {
queue = append(queue, artifact)
}
}
slices.SortFunc(queue, compare)
// Each artifact enters the queue at most once, so insertion reuses the
// preallocated capacity.
ordered := make([]*GraphNode, 0, len(artifacts))
for head := 0; head < len(queue); head++ {
artifact := queue[head]
ordered = append(ordered, artifact)
for _, dependent := range dependents[artifact.key()] {
dependentKey := dependent.key()
remaining[dependentKey]--
if remaining[dependentKey] != 0 {
continue
}
index, _ := slices.BinarySearchFunc(
queue[head+1:],
dependent,
compare,
)
queue = slices.Insert(queue, head+1+index, dependent)
}
}
if len(ordered) != len(artifacts) {
return byID()
}
return ordered
}
// bisectNodeReferences returns the manifest requirement references that select
// node. A lock graph carries the exact bindings on the node, including several
// bindings for one artifact. An observation graph has no lock bindings, so the
// manifest references are matched by provider and project identity.
func bisectNodeReferences(doc *document.Manifest, node *GraphNode) []string {
if len(node.Requirements) > 0 {
references := slices.Clone(node.Requirements)
slices.Sort(references)
return slices.Compact(references)
}
var packages map[string]document.Requirement
switch node.Environment {
case server.EnvironmentServer:
packages = doc.Server.Packages[node.Ecosystem]
case server.EnvironmentMCDR:
if doc.MCDR != nil {
packages = doc.MCDR.Packages
}
}
references := make([]string, 0, len(packages))
for reference := range packages {
if bisectReferenceMatchesNode(reference, node) {
references = append(references, reference)
}
}
slices.Sort(references)
return references
}
func bisectReferenceMatchesNode(reference string, node *GraphNode) bool {
provider, project, err := document.ParseReference(reference)
if err != nil {
return false
}
if node.Provider != catalog.ProviderUnspecified && provider != node.Provider {
return false
}
if node.ProjectID != "" {
return project == node.ProjectID
}
if node.Provider != catalog.ProviderUnspecified {
return strings.TrimPrefix(node.ID, node.Provider.String()+":") == project
}
return node.ID == project
}
// requirementEnabled reports whether the manifest still enables the given
// requirement entry.
func requirementEnabled(
doc *document.Manifest,
environment server.Environment,
ecosystem catalog.Ecosystem,
reference string,
) bool {
var packages map[string]document.Requirement
switch environment {
case server.EnvironmentServer:
if doc.Server.Packages == nil {
return false
}
packages = doc.Server.Packages[ecosystem]
case server.EnvironmentMCDR:
if doc.MCDR == nil {
return false
}
packages = doc.MCDR.Packages
default:
return false
}
requirement, ok := packages[reference]
return ok && requirement.IsEnabled()
}
// completeBisectTransition writes the manifest target recorded by a pending
// transition, syncs the installation, and only then clears the pending record.
// The record is durable before the first manifest write, so a failure at any
// point leaves a state that the same leaf can resume. It returns the result
// the owning leaf should report.
func completeBisectTransition(
ctx context.Context,
ops *operation.Service,
workDir string,
logger *log.Logger,
session *bisectState,
) (bisectOutput, error) {
if err := ctx.Err(); err != nil {
return bisectOutput{}, err
}
pending := session.Pending
if pending == nil {
return bisectOutput{}, fmt.Errorf("invalid bisect state: no pending transition")
}
var (
targetDigest string
targetResolution string
enabled int
disabled int
restored int
err error
)
switch session.Phase {
case bisectPhaseActive:
enabled, disabled, targetDigest, targetResolution, err = prepareBisectRange(ctx, workDir, logger, session)
case bisectPhaseFound, bisectPhaseExhausted:
targetDigest, targetResolution, err = writeBisectOriginal(ctx, workDir, logger, session, pending.Step)
restored = countRequirements(session.Original)
default:
return bisectOutput{}, fmt.Errorf("invalid bisect state: unknown phase %q", session.Phase)
}
if err != nil {
return bisectOutput{}, err
}
return commitBisectTransition(
ctx,
ops,
workDir,
logger,
session,
*pending,
targetDigest,
targetResolution,
enabled,
disabled,
restored,
)
}
// commitBisectTransition syncs the staged manifest, then records the committed
// digest and clears the pending transition. A failure before the final write
// leaves both the staged manifest and the pending record for a retry.
func commitBisectTransition(
ctx context.Context,
ops *operation.Service,
workDir string,
logger *log.Logger,
session *bisectState,
pending bisectPending,
targetDigest string,
targetResolution string,
enabled, disabled, restored int,
) (bisectOutput, error) {
if err := ctx.Err(); err != nil {
return bisectOutput{}, err
}
// Sync even when the manifest already shows the target: the manifest
// states what should be installed, not what is installed. ExpectedManifest
// also makes a concurrent edit fail instead of being overwritten by
// whichever document the sync happened to read.
result, err := ops.Sync(
ctx,
workDir,
install.Options{ExpectedManifest: targetResolution},
)
if err != nil {
return bisectOutput{}, fmt.Errorf("sync installation: %w", err)
}
// The resolution fingerprint intentionally ignores name metadata, so sync
// can succeed after a concurrent rename. Validate the full digest before
// clearing pending; otherwise the session would record a stale Applied
// digest and reject every later command.
applied := targetDigest
err = withBisectWorkspace(ctx, workDir, logger, func() error {
current, err := document.ManifestFile(workDir).Read()
if err != nil {
return fmt.Errorf("read manifest after sync: %w", err)
}
currentDigest, err := bisectManifestDigest(current)
if err != nil {
return err
}
if currentDigest != targetDigest {
currentResolution, err := bisectManifestResolutionFingerprint(current)
if err != nil {
return err
}
if currentResolution != targetResolution {
return errBisectManifestChangedDuringSync
}
// Only fields resolution ignores changed, currently the server
// name. Adopt the rename into the original so reset preserves it
// instead of reverting a change the session already observed.
adoptBisectRename(session, current)
applied = currentDigest
}
if err := ctx.Err(); err != nil {
return err
}
session.Pending = nil
session.Applied = applied
return writeBisectState(workDir, session)
})
if err != nil {
return bisectOutput{}, err
}
output := bisectOutput{
Phase: session.Phase,
Complete: session.Phase != bisectPhaseActive,
State: currentBisectView(session),
Enabled: enabled,
Disabled: disabled,
Restored: restored,
Warnings: syncWarnings(result),
}
output.Message = bisectTransitionMessage(session, pending)
if session.Phase == bisectPhaseFound {
output.Found = &session.Mods[session.Left]
}
return output, nil
}
// bisectTransitionMessage names the outcome of the transition a leaf recorded.
func bisectTransitionMessage(session *bisectState, pending bisectPending) string {
switch pending.Step {
case bisectStepStart:
return "session started"
case bisectStepGood:
if session.Phase == bisectPhaseExhausted {
return "all remaining packages are good; no bad package found"
}
return fmt.Sprintf(
"marked %s good",
bisectModLabel(session.Mods[session.Left-1]),
)
case bisectStepBad:
if session.Phase == bisectPhaseFound {
return "found bad package"
}
return fmt.Sprintf(
"marked %s bad",
bisectModLabel(session.Mods[session.Right]),
)
case bisectStepReset:
return "session reset"
default:
return "unknown transition"
}
}
// bisectInterruptedError refuses a different leaf while a transition is
// pending. The target range already contains the recorded verdict, so letting
// another verdict advance it again would corrupt the search.
func bisectInterruptedError(pending *bisectPending) error {
if pending.Step == bisectStepReset {
return fmt.Errorf(
"bisect reset was interrupted; rerun `lucy bisect reset` to complete it",
)
}
return fmt.Errorf(
"bisect %s was interrupted; rerun `lucy bisect %s` to complete it or `lucy bisect reset` to abandon the session",
pending.Step,
pending.Step,
)
}
// bisectTerminalError refuses a new verdict after a session reached its
// terminal phase. Only reset may end the session, so the recorded verdict
// cannot silently change.
func bisectTerminalError(session *bisectState) error {
switch session.Phase {
case bisectPhaseFound:
return fmt.Errorf(
"bisect session already found %s; run `lucy bisect reset` before another verdict",
bisectModLabel(session.Mods[session.Left]),
)
case bisectPhaseExhausted:
return fmt.Errorf("bisect session already finished with no bad package; run `lucy bisect reset` before another verdict")
default:
return nil
}
}
// bisectStartRefusal explains why start cannot replace an existing session.
// The captured manifest lives only in the session file, so replacing that file
// would make the original unrecoverable.
func bisectStartRefusal(session *bisectState) error {
if session.Pending != nil {
if session.Pending.Step == bisectStepReset {
return fmt.Errorf(
"bisect reset was interrupted; rerun `lucy bisect reset` before starting another session",
)
}
return fmt.Errorf(
"bisect session is interrupted during %s; run `lucy bisect %s` to complete it or `lucy bisect reset` to abandon the session",
session.Pending.Step,
session.Pending.Step,
)
}
switch session.Phase {
case bisectPhaseFound:
return fmt.Errorf(
"bisect session already found %s; run `lucy bisect reset` before starting another session",
bisectModLabel(session.Mods[session.Left]),
)
case bisectPhaseExhausted:
return fmt.Errorf("bisect session already finished with no bad package; run `lucy bisect reset` before starting another session")
default:
return fmt.Errorf("bisect session already active; run `lucy bisect reset` before starting another session")
}
}
func actionBisectStart(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) (resultErr error) {
ctx, stop := CommandContext(cmd)
defer stop()
workDir := inv.Directory
lock, err := lockBisectSession(ctx, workDir, requireBisectStartWorkspace)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
if err := ctx.Err(); err != nil {
return err
}
existing, err := readBisectState(workDir)
if err != nil {
return err
}
if existing != nil {
if existing.Pending != nil && existing.Pending.Step == bisectStepStart {
output, err := completeBisectTransition(
ctx,
deps.Operations,
workDir,
inv.Logger,
existing,
)
if err != nil {
return err
}
return outputBisect(cmd, inv, output)
}
return bisectStartRefusal(existing)
}
var (
original *document.Manifest
originalDigest string
)
if err := withBisectWorkspace(ctx, workDir, inv.Logger, func() error {
doc, err := document.ManifestFile(workDir).Read()
if err != nil {
return fmt.Errorf("read manifest: %w", err)
}
original = doc
originalDigest, err = bisectManifestDigest(doc)
return err
}); err != nil {
return err
}
candidateOutput := NewOperationOutput(cmd, inv)
mods, candidateWarnings, err := bisectCandidates(
ctx,
deps.Router.ResolveArtifactByHash,
workDir,
original,
candidateOutput,
)
if err != nil {
return err
}
if len(mods) == 0 {
return outputBisect(
cmd,
inv,
bisectOutput{
Message: "no enabled package requirements found in this manifest",
Complete: true,
Warnings: candidateWarnings,
},
)
}
session := &bisectState{
Phase: bisectPhaseActive,
Mods: mods,
Original: original,
Right: len(mods) - 1,
Pending: &bisectPending{Step: bisectStepStart},
}
var (
enabled int
disabled int
matched int
targetDigest string
targetResolution string
)
if err := withBisectWorkspace(ctx, workDir, inv.Logger, func() error {
current, err := document.ManifestFile(workDir).Read()
if err != nil {
return fmt.Errorf("read manifest: %w", err)
}
currentDigest, err := bisectManifestDigest(current)
if err != nil {
return err
}
if currentDigest != originalDigest {
return fmt.Errorf(
"manifest changed during package selection; retry `lucy bisect start`",
)
}
mid := (session.Left + session.Right) / 2
enabled, disabled, matched, targetDigest, targetResolution, err = toggleBisectRange(current, session.Mods, mid)
if err != nil {
return err
}
session.Pending.Digest = targetDigest
session.Pending.Resolution = targetResolution
if err := ctx.Err(); err != nil {
return err
}
if err := createBisectState(workDir, session); err != nil {
return err
}
if matched > 0 {
if err := ctx.Err(); err != nil {
return err
}
if err := document.ManifestFile(workDir).Write(current); err != nil {
return fmt.Errorf("write manifest: %w", err)
}
}
return nil
}); err != nil {
return err
}
output, err := commitBisectTransition(
ctx,
deps.Operations,
workDir,
inv.Logger,
session,
*session.Pending,
targetDigest,
targetResolution,
enabled,
disabled,
0,
)
if err != nil {
return err
}
output.Warnings = append(candidateWarnings, output.Warnings...)
return outputBisect(cmd, inv, output)
}
func actionBisectGood(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) (resultErr error) {
ctx, stop := CommandContext(cmd)
defer stop()
workDir := inv.Directory
lock, err := lockBisectSession(ctx, workDir, requireBisectSession)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
if err := ctx.Err(); err != nil {
return err
}
session, err := loadBisectSession(workDir)
if err != nil {
return err
}
if session.Pending != nil {
if session.Pending.Step != bisectStepGood {
return bisectInterruptedError(session.Pending)
}
output, err := completeBisectTransition(
ctx,
deps.Operations,
workDir,
inv.Logger,
session,
)
if err != nil {
return err
}
return outputBisect(cmd, inv, output)
}
if err := bisectTerminalError(session); err != nil {
return err
}
mid := (session.Left + session.Right) / 2
session.Left = mid + 1
session.Pending = &bisectPending{Step: bisectStepGood}
if session.Left > session.Right {
session.Phase = bisectPhaseExhausted
}
output, err := completeBisectTransition(
ctx,
deps.Operations,
workDir,
inv.Logger,
session,
)
if err != nil {
return err
}
return outputBisect(cmd, inv, output)
}
func actionBisectBad(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) (resultErr error) {
ctx, stop := CommandContext(cmd)
defer stop()
workDir := inv.Directory
lock, err := lockBisectSession(ctx, workDir, requireBisectSession)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
if err := ctx.Err(); err != nil {
return err
}
session, err := loadBisectSession(workDir)
if err != nil {
return err
}
if session.Pending != nil {
if session.Pending.Step != bisectStepBad {
return bisectInterruptedError(session.Pending)
}
output, err := completeBisectTransition(
ctx,
deps.Operations,
workDir,
inv.Logger,
session,
)
if err != nil {
return err
}
return outputBisect(cmd, inv, output)
}
if err := bisectTerminalError(session); err != nil {
return err
}
mid := (session.Left + session.Right) / 2
session.Right = mid
session.Pending = &bisectPending{Step: bisectStepBad}
if session.Left == session.Right {
session.Phase = bisectPhaseFound
}
output, err := completeBisectTransition(
ctx,
deps.Operations,
workDir,
inv.Logger,
session,
)
if err != nil {
return err
}
return outputBisect(cmd, inv, output)
}
func actionBisectStatus(
cmd *cobra.Command,
inv *Invocation,
_ *Dependencies,
_ []string,
) (resultErr error) {
ctx, stop := CommandContext(cmd)
defer stop()
workDir := inv.Directory
lock, err := lockBisectSession(ctx, workDir, requireBisectSession)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
if err := ctx.Err(); err != nil {
return err
}
session, err := loadBisectSession(workDir)
if err != nil {
return err
}
output := bisectOutput{
Phase: session.Phase,
Complete: session.Phase != bisectPhaseActive,
State: currentBisectView(session),
}
if session.Phase == bisectPhaseFound {
output.Found = &session.Mods[session.Left]
}
if session.Pending != nil {
output.Pending = session.Pending.Step
output.Message = fmt.Sprintf(
"session interrupted during %s",
session.Pending.Step,
)
} else {
switch session.Phase {
case bisectPhaseFound:
output.Message = "session complete: bad package identified"
case bisectPhaseExhausted:
output.Message = "session complete: no bad package found"
default:
output.Message = "session active"
}
}
return outputBisect(cmd, inv, output)
}
func actionBisectReset(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) (resultErr error) {
ctx, stop := CommandContext(cmd)
defer stop()
workDir := inv.Directory
lock, err := lockBisectSession(ctx, workDir, requireBisectSession)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, lock.Close()) }()
if err := ctx.Err(); err != nil {
return err
}
session, err := loadBisectSession(workDir)
if err != nil {
return err
}
// Reset supersedes any pending transition: restoring the captured
// manifest and syncing it is the final state, whatever the interrupted
// leaf had intended. The session file is kept until this succeeds, so a
// failed restore remains retryable.
_, targetResolution, err := writeBisectOriginal(
ctx,
workDir,
inv.Logger,
session,
bisectStepReset,
)
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
result, err := deps.Operations.Sync(
ctx,
workDir,
install.Options{ExpectedManifest: targetResolution},
)
if err != nil {
return fmt.Errorf("sync installation: %w", err)
}
if err := ctx.Err(); err != nil {
return err
}
if err := deleteBisectState(workDir); err != nil {
return err
}
return outputBisect(
cmd,
inv,
bisectOutput{
Message: "session reset",
Complete: true,
Restored: countRequirements(session.Original),
Warnings: syncWarnings(result),
},
)
}
package cli
import (
"fmt"
"slices"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/terminal"
"github.com/mclucy/lucy/terminal/style"
"github.com/spf13/cobra"
)
// cacheLsJSON is the machine-readable `cache ls` result: every cached entry.
var cacheLsJSON JSONResult[[]*cache.Entry]
// cacheClearJSON is the machine-readable `cache clear` result.
var cacheClearJSON JSONResult[cache.ResetReport]
func NewCacheCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
cacheLsCmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: help.CacheLsShort,
RunE: func(cmd *cobra.Command, args []string) error {
return actionCacheLs(cmd, inv, deps, args)
},
}
cacheClearCmd := &cobra.Command{
Use: "clear",
Aliases: []string{"rm"},
Short: help.CacheClearShort,
RunE: func(cmd *cobra.Command, args []string) error {
return actionCacheClear(cmd, inv, deps, args)
},
}
cacheCmd := &cobra.Command{
Use: "cache",
Short: help.CacheShort,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
MarkHelpOnly(cacheCmd)
cacheLsJSON.Bind(contracts, cacheLsCmd)
cacheClearJSON.Bind(contracts, cacheClearCmd)
cacheCmd.AddCommand(cacheLsCmd, cacheClearCmd)
return cacheCmd
}
func actionCacheLs(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) error {
output := NewQueryOutput(cmd, inv)
entries := deps.Network.All()
if output.JSON() {
return cacheLsJSON.Write(output, entries)
}
if len(entries) == 0 {
_, err := fmt.Fprintln(output.Human(), "Cache is empty")
return err
}
slices.SortFunc(entries, func(a, b *cache.Entry) int {
return b.CreatedAt.Compare(a.CreatedAt)
})
facts := make([]terminal.Fact, 0, len(entries)+1)
facts = append(facts, terminal.Fact{
Key: "Cache",
Value: fmt.Sprintf("%d entries", len(entries)),
})
for _, entry := range entries {
facts = append(facts, terminal.Fact{
Key: entry.Key,
Value: fmt.Sprintf(
"%s %s %s",
entry.Kind,
style.FormatBytesBinary(entry.Size),
inv.Theme.Muted(style.FormatTTL(entry.Expiration)),
),
})
}
_, err := fmt.Fprintln(
output.Human(),
terminal.RenderFacts(facts, inv.Stdout.Width, inv.Theme),
)
return err
}
func actionCacheClear(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) error {
output := NewOperationOutput(cmd, inv)
cleared, err := deps.Network.ClearAll()
if err != nil {
return fmt.Errorf("failed to clear cache: %w", err)
}
if output.JSON() {
return cacheClearJSON.Write(output, cleared)
}
// The cache backend only collects the successful removals; the command
// presents them, in the order the reset walked the cache directory.
out := output.Human()
for _, removed := range cleared.Removed {
if _, err := fmt.Fprintf(out, "removed %s\n", removed); err != nil {
return err
}
}
_, err = fmt.Fprintf(
out,
"all cache items cleared\nremoved %d files, freed up %s of space\n",
cleared.FileCount,
style.FormatBytesBinary(cleared.TotalFreedSize),
)
return err
}
package cli
import (
"context"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/spf13/cobra"
)
type CompletionCandidate struct {
Value string
Description string
}
// FilterByPrefix keeps candidates whose Value starts with prefix,
// matching case-insensitively.
func FilterByPrefix(
candidates []CompletionCandidate,
prefix string,
) []CompletionCandidate {
if prefix == "" {
return candidates
}
lower := strings.ToLower(prefix)
var out []CompletionCandidate
for _, c := range candidates {
if strings.HasPrefix(strings.ToLower(c.Value), lower) {
out = append(out, c)
}
}
return out
}
// ToCobraCompletions formats candidates as cobra's "value\tDescription" lines.
func ToCobraCompletions(candidates []CompletionCandidate) []string {
out := make([]string, 0, len(candidates))
for _, c := range candidates {
if c.Description != "" {
out = append(out, c.Value+"\t"+c.Description)
} else {
out = append(out, c.Value)
}
}
return out
}
// StaticEcosystemCandidates covers all user-facing ecosystems.
func StaticEcosystemCandidates() []CompletionCandidate {
return []CompletionCandidate{
{
Value: catalog.EcosystemMinecraft.String(),
Description: "Vanilla / Bukkit / Paper plugins",
},
{Value: catalog.EcosystemFabric.String(), Description: "Fabric mods"},
{Value: catalog.EcosystemForge.String(), Description: "Forge mods"},
{Value: catalog.EcosystemNeoForge.String(), Description: "NeoForge mods"},
{
Value: catalog.EcosystemMCDR.String(),
Description: "MCDR controller / plugin framework",
},
}
}
func StaticStatusLogoCandidates() []CompletionCandidate {
return []CompletionCandidate{
{Value: "small", Description: "Compact logo (default)"},
{Value: "none", Description: "Hide the logo"},
{Value: "large", Description: "Full-size logo"},
}
}
// StaticSearchEcosystemCandidates is the search rollout subset, not the full
// ecosystem set.
func StaticSearchEcosystemCandidates() []CompletionCandidate {
return []CompletionCandidate{
{Value: catalog.EcosystemFabric.String(), Description: "Fabric mods"},
{Value: catalog.EcosystemForge.String(), Description: "Forge mods"},
{Value: catalog.EcosystemNeoForge.String(), Description: "NeoForge mods"},
{Value: catalog.EcosystemBukkit.String(), Description: "Bukkit/Spigot plugins"},
{Value: catalog.EcosystemPaper.String(), Description: "Paper plugins"},
}
}
func StaticSearchRegistryCandidates() []CompletionCandidate {
return []CompletionCandidate{
{Value: catalog.RegistryModrinth.String(), Description: "Modrinth"},
{Value: catalog.RegistryCurseForge.String(), Description: "CurseForge"},
{Value: catalog.RegistryHangar.String(), Description: "Hangar"},
{Value: catalog.RegistrySpiget.String(), Description: "Spiget"},
{Value: catalog.RegistryMCDR.String(), Description: "MCDR Plugin Catalogue"},
}
}
func StaticVersionCandidates() []CompletionCandidate {
return []CompletionCandidate{
{Value: "any", Description: "Latest version, any stability (default)"},
{Value: "stable", Description: "Latest stable release, no betas"},
{Value: "beta", Description: "Latest version, allow pre-releases"},
}
}
// LockCandidates lists the package references the current lock resolves, with
// their versions. It is the only source of workspace package suggestions;
// nothing is inferred from outside the lock.
func LockCandidates(root string) []CompletionCandidate {
lock, err := document.LockFile(root).Read()
if err != nil {
return nil
}
out := make([]CompletionCandidate, 0, len(lock.Packages))
for _, pkg := range lock.Packages {
reference := pkg.Provider.String() + ":" + pkg.ProjectID
description := string(pkg.Environment) + "/" + pkg.Ecosystem.String() + " " + pkg.Version.String()
out = append(out, CompletionCandidate{
Value: reference,
Description: description,
})
}
return out
}
// ResolveLockReference resolves a native module name to the single manifest
// reference the current lock binds it to. An empty result means the lock
// resolves no such package; an error means the name is ambiguous and the
// caller must supply an explicit "provider:project".
func ResolveLockReference(root, name string) (string, error) {
lock, err := document.LockFile(root).Read()
if err != nil {
return "", nil
}
return lock.LookupReference(name)
}
// CompletePackageIDSuggestions completes a "provider:project@version" token
// for the invocation workspace. Workspace suggestions come from that
// workspace's lock, which is the only evidence of what it contains.
func CompletePackageIDSuggestions(
_ context.Context,
root string,
token string,
) ([]string, cobra.ShellCompDirective) {
_, name, version, segment := ParseCompletionToken(token)
switch segment {
case "version":
candidates := FilterByPrefix(StaticVersionCandidates(), version)
return ToCobraCompletions(candidates), cobra.ShellCompDirectiveNoFileComp
case "name":
candidates := FilterByPrefix(LockCandidates(root), name)
return ToCobraCompletions(candidates), cobra.ShellCompDirectiveNoFileComp
default:
return nil, cobra.ShellCompDirectiveNoFileComp
}
}
// ParseCompletionToken parses a partial "provider:project@version" completion
// token. Target ecosystem is selected by --ecosystem, not package syntax.
func ParseCompletionToken(token string) (provider, name, version, segment string) {
provider = "auto"
beforeVersion := token
if before, after, ok := strings.Cut(token, "@"); ok {
beforeVersion = before
version = after
segment = "version"
}
if before, after, ok := strings.Cut(beforeVersion, ":"); ok {
provider = before
name = after
if segment == "" {
segment = "name"
}
return
}
name = beforeVersion
if segment == "" {
segment = "name"
}
return
}
package cli
import (
"fmt"
"io"
"github.com/mclucy/lucy/config"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
const (
flagConfigGlobalName = "global"
flagConfigForceName = "force"
flagConfigNoCommentsName = "no-comments"
)
// configValuesJSON, configJavaJSON, configGitHubJSON, configCurseForgeJSON,
// and configStringJSON are the shapes `lucy config get` prints: a whole layer,
// a section, or a scalar leaf.
var (
configValuesJSON JSONResult[*config.Values]
configJavaJSON JSONResult[config.Java]
configGitHubJSON JSONResult[config.GitHub]
configCurseForgeJSON JSONResult[config.CurseForge]
configStringJSON JSONResult[string]
)
// configWriteJSON is the machine-readable result of `lucy config set` and
// `lucy config create`.
var configWriteJSON JSONResult[*config.WriteResult]
func NewConfigCommand(
contracts *JSONContracts,
inv *Invocation,
_ *Dependencies,
) *cobra.Command {
configGetCmd := &cobra.Command{
Use: "get [key]",
Short: help.ConfigGetShort,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return actionConfigGet(cmd, inv, args)
},
}
configSetCmd := &cobra.Command{
Use: "set <key> <value>",
Short: help.ConfigSetShort,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return actionConfigSet(cmd, inv, args)
},
}
configCreateCmd := &cobra.Command{
Use: "create",
Short: help.ConfigCreateShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return actionConfigCreate(cmd, inv)
},
}
configCmd := &cobra.Command{
Use: "config",
Short: help.ConfigShort,
}
configValuesJSON.Bind(contracts, configGetCmd)
configJavaJSON.Bind(contracts, configGetCmd)
configGitHubJSON.Bind(contracts, configGetCmd)
configCurseForgeJSON.Bind(contracts, configGetCmd)
configStringJSON.Bind(contracts, configGetCmd)
configWriteJSON.Bind(contracts, configSetCmd)
configWriteJSON.Bind(contracts, configCreateCmd)
configCmd.PersistentFlags().Bool(
flagConfigGlobalName,
false,
"Use the global configuration instead of the workspace configuration",
)
configCreateCmd.Flags().BoolP(
flagConfigForceName,
"f",
false,
"Overwrite an existing configuration file",
)
configCreateCmd.Flags().Bool(
flagConfigNoCommentsName,
false,
"Write the configuration without explanatory comments",
)
configCmd.AddCommand(configGetCmd, configSetCmd, configCreateCmd)
return configCmd
}
func actionConfigGet(
cmd *cobra.Command,
inv *Invocation,
args []string,
) error {
service, err := newConfigService(inv)
if err != nil {
return err
}
key := ""
if len(args) == 1 {
key = args[0]
}
value, err := service.Get(key, configScope(cmd))
if err != nil {
return err
}
if !config.Secret(key) {
value = config.Redacted(value)
}
output := NewQueryOutput(cmd, inv)
if output.JSON() {
return writeConfigJSON(output, value)
}
return writeConfigValue(output.Human(), key, value)
}
// writeConfigJSON writes the shape the selected key produced.
func writeConfigJSON(output *Output, value any) error {
switch value := value.(type) {
case *config.Values:
return configValuesJSON.Write(output, value)
case config.Java:
return configJavaJSON.Write(output, value)
case config.GitHub:
return configGitHubJSON.Write(output, value)
case config.CurseForge:
return configCurseForgeJSON.Write(output, value)
case string:
return configStringJSON.Write(output, value)
default:
return fmt.Errorf("unsupported config value type %T", value)
}
}
func actionConfigSet(
cmd *cobra.Command,
inv *Invocation,
args []string,
) error {
service, err := newConfigService(inv)
if err != nil {
return err
}
key, value := args[0], args[1]
result, err := service.Set(key, value, configScope(cmd))
if err != nil {
return err
}
inv.Logger.Info(
"configuration updated",
"path", result.Path,
"key", key,
"scope", configScopeLabel(cmd),
"created", result.Created,
)
output := NewOperationOutput(cmd, inv)
if output.JSON() {
return configWriteJSON.Write(output, result)
}
out := output.Human()
if result.Created {
if _, err := fmt.Fprintf(out, "Created %s\n", result.Path); err != nil {
return err
}
}
shown := value
if config.Secret(key) && shown != "" {
shown = config.RedactedValue
}
_, err = fmt.Fprintf(out, "Set %s to %s\n", key, shown)
return err
}
func actionConfigCreate(cmd *cobra.Command, inv *Invocation) error {
service, err := newConfigService(inv)
if err != nil {
return err
}
force, err := cmd.Flags().GetBool(flagConfigForceName)
if err != nil {
return err
}
noComments, err := cmd.Flags().GetBool(flagConfigNoCommentsName)
if err != nil {
return err
}
result, err := service.Create(
configScope(cmd),
config.CreateOptions{Force: force, NoComments: noComments},
)
if err != nil {
return err
}
inv.Logger.Info(
"configuration written",
"path", result.Path,
"scope", configScopeLabel(cmd),
"created", result.Created,
)
output := NewOperationOutput(cmd, inv)
if output.JSON() {
return configWriteJSON.Write(output, result)
}
verb := "Wrote"
if result.Created {
verb = "Created"
}
_, err = fmt.Fprintf(output.Human(), "%s %s\n", verb, result.Path)
return err
}
func newConfigService(inv *Invocation) (*config.Service, error) {
return config.New(inv.Directory, inv.Logger)
}
func configScopeLabel(cmd *cobra.Command) string {
if configScope(cmd) == config.GlobalScope {
return "global"
}
return "workspace"
}
func configScope(cmd *cobra.Command) config.Scope {
global, _ := cmd.Flags().GetBool(flagConfigGlobalName)
if global {
return config.GlobalScope
}
return config.WorkspaceScope
}
// writeConfigValue renders the value of a key for people: scalar values print
// verbatim, while a section prints as YAML. The whole effective configuration
// also prints as YAML.
func writeConfigValue(out io.Writer, key string, value any) error {
if key != "" {
switch value := value.(type) {
case nil:
_, err := fmt.Fprintln(out)
return err
case string:
_, err := fmt.Fprintln(out, value)
return err
}
}
data, err := yaml.Marshal(value)
if err != nil {
return fmt.Errorf("encode configuration: %w", err)
}
_, err = out.Write(data)
return err
}
package cli
import (
"context"
"github.com/spf13/cobra"
)
// CommandContext derives the command's context. Signal handling belongs to
// the process boundary, which cancels the context the root was executed
// with; commands only propagate cancellation into their work.
func CommandContext(cmd *cobra.Command) (context.Context, context.CancelFunc) {
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
return context.WithCancel(ctx)
}
package cli
// CommandContract defines the durable semantic boundary of a user-facing
// command. Contracts are intentionally stricter than the current
// implementation.
type CommandContract struct {
Name string
Summary string
MutatesManifest bool
MutatesLockfile bool
MutatesRuntime bool
ObservesRuntime bool
ManifestEffect string
LockfileEffect string
RuntimeEffect string
Guardrails []string
}
var (
addContract = CommandContract{
Name: "add",
Summary: "Insert or upgrade required intent, then resolve the resulting closure.",
MutatesManifest: true,
MutatesLockfile: true,
MutatesRuntime: false,
ObservesRuntime: true,
ManifestEffect: "Insert a missing package as required intent or upgrade the existing required intent for the addressed package.",
LockfileEffect: "Resolve the full closure implied by the manifest after the required-intent change and record exact versions, sources, hashes, install paths, and provenance.",
RuntimeEffect: "No direct runtime synchronization contract. Runtime drift may be observed for compatibility checks and warnings, but runtime reconciliation belongs to install.",
Guardrails: []string{
"Must not delete unmanaged content.",
"Must not treat ignored entries as required or transitive.",
"Must not use add as a generic sync/apply command.",
},
}
removeContract = CommandContract{
Name: "remove",
Summary: "Remove required intent, then prune transitive dependencies that are no longer needed.",
MutatesManifest: true,
MutatesLockfile: true,
MutatesRuntime: false,
ObservesRuntime: true,
ManifestEffect: "Remove the addressed package from required intent only; ignored entries remain ignored, and unrelated required roots remain intact.",
LockfileEffect: "Re-resolve the closure after the required-intent removal and prune no longer needed transitive dependencies while keeping packages still reachable from another required root.",
RuntimeEffect: "No direct runtime synchronization contract. Runtime drift may be inspected for warnings, but file deletion/application belongs to install.",
Guardrails: []string{
"Must not remove ignored content.",
"Must not delete still-required packages or still-needed transitives.",
"Must not claim ownership of unmanaged runtime files.",
},
}
upgradeContract = CommandContract{
Name: "upgrade",
Summary: "Advance declared package roots to newer eligible builds and reconcile the lock.",
MutatesManifest: true,
MutatesLockfile: true,
MutatesRuntime: true,
ObservesRuntime: true,
ManifestEffect: "Rewrite exact selectors of upgraded roots to the selected version; leave ambiguous selectors unchanged.",
LockfileEffect: "Resolve the full closure with targeted roots advanced and non-target roots held at their locked builds.",
RuntimeEffect: "Apply the resolved lock through the install transaction. Game, server core, and MCDR runtime changes belong to migration.",
Guardrails: []string{
"Must not change the game version, server core, loaders, or MCDR runtime.",
"Must not move a manifest root below its locked version.",
"Must not special-case package names in resolution.",
"Must not mutate ignored or unmanaged content.",
},
}
installContract = CommandContract{
Name: "install",
Summary: "Synchronize managed runtime state to manifest intent using exact lockfile facts.",
MutatesManifest: false,
MutatesLockfile: true,
MutatesRuntime: true,
ObservesRuntime: true,
ManifestEffect: "None. Install never rewrites desired intent.",
LockfileEffect: "Materialize or refresh the exact resolved closure needed to satisfy the current manifest, then use that lockfile as the source of truth for managed runtime sync.",
RuntimeEffect: "Create, replace, or prune only managed-scope runtime artifacts whose exact state differs from the lockfile. Ignored and unmanaged content are observed boundaries, not deletion targets.",
Guardrails: []string{
"Must not mean delete everything not mentioned in the manifest.",
"Must not mutate ignored entries or unmanaged paths.",
"Must treat probe output as observed state for drift detection, not as manifest intent.",
},
}
)
func CommandContracts() map[string]CommandContract {
return map[string]CommandContract{
addContract.Name: addContract,
removeContract.Name: removeContract,
installContract.Name: installContract,
upgradeContract.Name: upgradeContract,
}
}
package cli
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/javainstall"
"github.com/mclucy/lucy/operation"
"github.com/mclucy/lucy/report"
serverpkg "github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/upstream/routing"
"github.com/mclucy/lucy/version"
"github.com/spf13/cobra"
)
const (
flagCreateForceName = "force"
flagCreateCoreName = "core"
flagCreateCoreVersionName = "core-version"
flagCreateMcName = "minecraft"
flagCreateMCDRVersionName = "mcdr-version"
flagCreateDirectoryName = "directory"
flagCreateNameName = "name"
flagCreateInstallName = "install"
flagCreateNoInstallName = "no-install"
flagCreateJavaName = "java"
flagCreateJavaRepoName = "add-java-repository"
createProductMinecraft = "minecraft"
)
// Keywords accepted for the Minecraft version of a new server. Release and
// snapshot name Mojang's channels; latest asks the chosen product which
// version it serves.
const (
createMinecraftLatest = "latest"
createMinecraftRelease = "release"
createMinecraftSnapshot = "snapshot"
)
func NewCreateCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Short: help.CreateShort,
Long: help.CreateLong,
Example: " lucy create\n" +
" lucy create --minecraft 1.21.4 --core paper\n" +
" lucy create --directory server --minecraft 1.21.4 --core paper --no-install",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionCreate(cmd, inv, deps, args)
},
}
createJSON.Bind(contracts, cmd)
cmd.Flags().BoolP(
flagCreateForceName,
"f",
false,
"Replace an existing manifest in the target directory",
)
cmd.Flags().StringP(
flagCreateCoreName,
"c",
"",
"Server software, optionally with a version (e.g. paper@232, fabric@0.16.9)",
)
cmd.Flags().String(
flagCreateCoreVersionName,
"",
"Server software version or version rule",
)
cmd.Flags().StringP(
flagCreateMcName,
"m",
"",
"Target Minecraft version (e.g. 1.21.4), release, snapshot, or latest",
)
cmd.Flags().String(
flagCreateMCDRVersionName,
"",
"Record this MCDReforged version",
)
cmd.Flags().StringP(
flagCreateDirectoryName,
"d",
".",
"Directory to create the server in",
)
cmd.Flags().String(
flagCreateNameName,
"",
"Server name to record (default: the target directory name)",
)
cmd.Flags().Bool(
flagCreateInstallName,
false,
"Install the server after writing the manifest and lock (default)",
)
cmd.Flags().Bool(
flagCreateNoInstallName,
false,
"Only write lucy.yaml and lucy-lock.yaml; install later",
)
cmd.Flags().Bool(
flagCreateJavaName,
false,
"Also install the Java runtime the server asks for when no installed runtime meets it",
)
cmd.Flags().Bool(
flagCreateJavaRepoName,
false,
"Let --java add the Java vendor's package repository to the system package manager",
)
return cmd
}
// createChoices carries the values the command has settled before the form or
// the operation. Product is the selector value.
type createChoices struct {
Directory string
Name string
Product string
Minecraft string
CoreVersion string
MCDRVersion string
Force bool
Install bool
InstallChosen bool
Java bool
AddJavaRepository bool
}
func actionCreate(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) error {
ctx, stop := CommandContext(cmd)
defer stop()
choices, err := choicesFromCreateFlags(cmd)
if err != nil {
return err
}
interactive := NewPromptGate(cmd, inv).Available()
if interactive {
choices, err = promptCreate(ctx, cmd, deps.Router, inv.Directory, choices)
if err != nil {
if errors.Is(err, context.Canceled) {
return &report.Detail{
Title: "creation cancelled",
Lines: []string{"Nothing was written."},
Cause: err,
}
}
return err
}
if err := validateCreateChoices(choices); err != nil {
return err
}
} else if err := validateCreateChoices(choices); err != nil {
return err
}
if !interactive {
if err := warnCreateTarget(cmd, inv, choices); err != nil {
return err
}
}
choices.Minecraft, err = resolveCreateMinecraft(
ctx,
deps.Router,
createProduct(choices.Product),
choices.Minecraft,
)
if err != nil {
return err
}
opts := createOperationOptions(choices, inv.Directory)
output := newCreateOutput(cmd, inv)
defer output.Close()
opts.Trace = output.Trace()
if choices.Java && choices.Install {
opts.InstallOptions.ProvisionJava = output.provisionJava(
deps.JavaHost,
javainstall.Policy{
Interactive: NewPromptGate(cmd, inv).Available(),
AllowRepository: choices.AddJavaRepository,
},
false,
)
}
result, err := deps.Operations.Create(ctx, opts)
if err != nil {
return output.Failure(err)
}
return output.Render(result)
}
func choicesFromCreateFlags(cmd *cobra.Command) (createChoices, error) {
installFlag, _ := cmd.Flags().GetBool(flagCreateInstallName)
noInstallFlag, _ := cmd.Flags().GetBool(flagCreateNoInstallName)
installSet := cmd.Flags().Changed(flagCreateInstallName)
noInstallSet := cmd.Flags().Changed(flagCreateNoInstallName)
installChosen := installSet || noInstallSet
if installSet && noInstallSet && installFlag == noInstallFlag {
return createChoices{}, errors.New("--install and --no-install contradict each other")
}
install := true
if cmd.Flags().Changed(flagCreateInstallName) {
install = installFlag
}
if cmd.Flags().Changed(flagCreateNoInstallName) {
install = !noInstallFlag
}
javaFlag, _ := cmd.Flags().GetBool(flagCreateJavaName)
javaRepository, _ := cmd.Flags().GetBool(flagCreateJavaRepoName)
if javaRepository && !javaFlag {
return createChoices{}, fmt.Errorf("--%s needs --%s", flagCreateJavaRepoName, flagCreateJavaName)
}
name := strings.TrimSpace(flagStringCreate(cmd, flagCreateNameName))
if cmd.Flags().Changed(flagCreateNameName) {
if _, err := document.NormalizeName(name); err != nil {
return createChoices{}, fmt.Errorf("--%s: %w", flagCreateNameName, err)
}
}
coreRaw := flagStringCreate(cmd, flagCreateCoreName)
productName, inlineVersion := parseCoreDistributionCreate(coreRaw)
if explicitVersion := flagStringCreate(cmd, flagCreateCoreVersionName); explicitVersion != "" {
if inlineVersion != "" && inlineVersion != explicitVersion {
return createChoices{}, fmt.Errorf(
"--core includes @%s but --core-version is %s; use one version",
inlineVersion,
explicitVersion,
)
}
inlineVersion = explicitVersion
}
choices := createChoices{
Directory: flagStringCreate(cmd, flagCreateDirectoryName),
Name: name,
Minecraft: strings.TrimSpace(flagStringCreate(cmd, flagCreateMcName)),
CoreVersion: strings.TrimSpace(inlineVersion),
MCDRVersion: strings.TrimSpace(flagStringCreate(cmd, flagCreateMCDRVersionName)),
Force: flagBoolCreate(cmd, flagCreateForceName),
Install: install,
InstallChosen: installChosen,
Java: javaFlag,
AddJavaRepository: javaRepository,
}
productName = strings.TrimSpace(productName)
if productName == "" {
if choices.CoreVersion != "" {
return createChoices{}, fmt.Errorf("--%s needs --%s", flagCreateCoreVersionName, flagCreateCoreName)
}
choices.Product = createProductMinecraft
return choices, nil
}
product, known := serverpkg.ParseProduct(productName)
if !known || !createProductSupported(product) {
return createChoices{}, fmt.Errorf(
"server software %q is not supported by create; choose minecraft, paper, purpur, fabric, forge, or neoforge",
productName,
)
}
if product == serverpkg.ProductMinecraft {
if choices.CoreVersion != "" && choices.CoreVersion != choices.Minecraft {
return createChoices{}, fmt.Errorf(
"--%s does not apply to the Minecraft core; the Minecraft version is the server version",
flagCreateCoreVersionName,
)
}
choices.CoreVersion = ""
}
choices.Product = string(product)
return choices, nil
}
func validateCreateChoices(choices createChoices) error {
if strings.TrimSpace(choices.Directory) == "" {
return errors.New("choose a directory with --directory")
}
if choices.Name != "" {
if _, err := document.NormalizeName(choices.Name); err != nil {
return fmt.Errorf("--%s: %w", flagCreateNameName, err)
}
}
if err := validateCreateMinecraft(
choices.Minecraft,
createProduct(choices.Product),
); err != nil {
return fmt.Errorf("--%s: %w", flagCreateMcName, err)
}
if choices.Java && !choices.Install {
return errors.New("--java needs installation; omit --no-install or choose Install")
}
return nil
}
func createOperationOptions(
choices createChoices,
base string,
) operation.CreateOptions {
product := choices.Product
if product == "" {
product = createProductMinecraft
}
return operation.CreateOptions{
Directory: resolveInvocationPath(base, choices.Directory),
Name: choices.Name,
Minecraft: choices.Minecraft,
Product: product,
ProductVersion: choices.CoreVersion,
MCDRVersion: choices.MCDRVersion,
Force: choices.Force,
Install: choices.Install,
}
}
func warnCreateTarget(
cmd *cobra.Command,
inv *Invocation,
choices createChoices,
) error {
target, err := operation.InspectCreateTarget(
resolveInvocationPath(inv.Directory, choices.Directory),
)
if err != nil {
return err
}
if target.ManifestExists && !choices.Force {
return fmt.Errorf(
"lucy.yaml exists in %s; pass --force to replace declared intent",
target.Path,
)
}
if !target.NonEmpty {
return nil
}
location := displayCreatePath(target.Path, inv.Directory)
message := "the current directory is not empty"
if location != "." {
message = "directory " + location + " is not empty"
}
if len(target.Entries) > 0 {
message += " (" + strings.Join(target.Entries, ", ") + ")"
}
message += "; existing files are left in place"
if target.ManifestExists {
message += ", and lucy.yaml will be replaced"
if target.DeclaredPackages > 0 {
message += " (" + createPackageCountLabel(target.DeclaredPackages) +
" declared; installation may remove their files)"
}
}
if JSONMode(cmd) == JSONNone {
report.New(cmd.ErrOrStderr(), inv.Theme).Warn(errors.New(message))
}
return nil
}
func parseCoreDistributionCreate(raw string) (distribution, coreVersion string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", ""
}
if dist, ver, found := strings.Cut(raw, "@"); found {
return dist, ver
}
return raw, ""
}
func flagStringCreate(cmd *cobra.Command, name string) string {
value, _ := cmd.Flags().GetString(name)
return value
}
func flagBoolCreate(cmd *cobra.Command, name string) bool {
value, _ := cmd.Flags().GetBool(name)
return value
}
func createProductSupported(product serverpkg.Product) bool {
switch product {
case serverpkg.ProductMinecraft,
serverpkg.ProductPaper,
serverpkg.ProductPurpur,
serverpkg.ProductFabric,
serverpkg.ProductForge,
serverpkg.ProductNeoForge:
return true
default:
return false
}
}
// createProduct names the server product a choice selects, defaulting to
// vanilla.
func createProduct(name string) serverpkg.Product {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return serverpkg.ProductMinecraft
}
if parsed, known := serverpkg.ParseProduct(trimmed); known {
return parsed
}
return serverpkg.Product(strings.ToLower(trimmed))
}
// createMinecraftKeyword maps a floating game version request, including the
// loader channel spellings, to its canonical keyword.
func createMinecraftKeyword(value string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case createMinecraftLatest:
return createMinecraftLatest, true
case createMinecraftRelease, string(version.Stable):
return createMinecraftRelease, true
case createMinecraftSnapshot, string(version.Beta):
return createMinecraftSnapshot, true
default:
return "", false
}
}
func validateCreateMinecraft(value string, product serverpkg.Product) error {
value = strings.TrimSpace(value)
if value == "" {
return errors.New("enter a Minecraft version")
}
if keyword, ok := createMinecraftKeyword(value); ok {
if keyword != createMinecraftLatest && !productFollowsMinecraftReleases(product) {
return fmt.Errorf(
"%s builds for Minecraft releases only and lags behind them; "+
"name an exact version or use latest",
product,
)
}
return nil
}
parsed := version.Version(value)
if parsed.CanInfer() || parsed.IsInvalid() {
return errors.New(
"enter a Minecraft version, e.g. 1.21.4, release, snapshot, or latest",
)
}
return nil
}
// productFollowsMinecraftReleases reports whether the product publishes builds
// as Minecraft versions ship. Vanilla always does, and Fabric tracks both
// releases and snapshots. Forge, NeoForge, Paper, and Purpur build releases
// only and lag behind them, so a channel keyword would name a version they do
// not serve yet.
func productFollowsMinecraftReleases(product serverpkg.Product) bool {
switch product {
case serverpkg.ProductMinecraft, serverpkg.ProductFabric:
return true
default:
return false
}
}
// resolveCreateMinecraft turns a floating keyword into the version the
// manifest records. The interactive form and the flags share this step.
func resolveCreateMinecraft(
ctx context.Context,
router *routing.Router,
product serverpkg.Product,
value string,
) (string, error) {
keyword, ok := createMinecraftKeyword(value)
if !ok {
return strings.TrimSpace(value), nil
}
var (
resolved version.Version
err error
)
switch keyword {
case createMinecraftLatest:
resolved, err = router.LatestMinecraftVersion(ctx, product)
case createMinecraftRelease:
resolved, err = router.LatestMojangMinecraft(ctx, false)
case createMinecraftSnapshot:
resolved, err = router.LatestMojangMinecraft(ctx, true)
}
if err != nil {
return "", fmt.Errorf("--%s: %w", flagCreateMcName, err)
}
return resolved.String(), nil
}
// displayCreatePath keeps the form and the final summary short when the
// target is below the current directory.
func displayCreatePath(path, base string) string {
absolute := resolveInvocationPath(base, path)
relative, err := filepath.Rel(base, absolute)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return absolute
}
return relative
}
// displayCreateLocation names the current directory in prose instead of
// leaving a bare dot in a sentence.
// resolveInvocationPath anchors a user-supplied path at the invocation
// directory. Handlers never rely on the process working directory.
func resolveInvocationPath(base, path string) string {
path = strings.TrimSpace(path)
if path == "" {
return filepath.Clean(base)
}
if filepath.IsAbs(path) {
return filepath.Clean(path)
}
return filepath.Join(base, path)
}
func displayCreateLocation(path, base string) string {
display := displayCreatePath(path, base)
if display == "." {
return "the current directory"
}
return display
}
package cli
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"sync/atomic"
"unicode"
"charm.land/huh/v2"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/operation"
serverpkg "github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/upstream/routing"
"github.com/spf13/cobra"
)
// promptCreate collects the choices for a new workspace. Flags prefill the
// form; the form remains the authority for an interactive invocation.
func promptCreate(
ctx context.Context,
cmd *cobra.Command,
router *routing.Router,
base string,
defaults createChoices,
) (createChoices, error) {
values := defaults
forceFromFlag := values.Force
if values.Directory == "" {
values.Directory = "."
}
// The name follows the directory until the operator supplies one, so the
// prefilled default never outlives the directory it was derived from.
derivedName := defaultCreateName(values.Directory, base)
nameFromFlag := values.Name != ""
if !nameFromFlag {
values.Name = derivedName
}
if values.Product == "" {
values.Product = createProductMinecraft
}
nameAccessor := huh.NewPointerAccessor(&values.Name)
name := huh.NewInput().
Key("name").
Title("Name").
Description("Select this server by name with --server. Defaults to the target directory name.").
Placeholder("survival").
Accessor(nameAccessor).
Validate(validateCreateName)
directory := huh.NewInput().
Key("directory").
Title("Directory").
DescriptionFunc(func() string {
return describeCreateDirectory(values.Directory, values.Force, base)
}, &values.Directory).
Placeholder(".").
Value(&values.Directory).
Validate(func(path string) error {
if err := validateCreateDirectory(path, base); err != nil {
return err
}
if nameFromFlag || values.Name != derivedName {
return nil
}
derivedName = defaultCreateName(path, base)
values.Name = derivedName
// huh reads the text field, not the accessor, while editing, so
// reattaching the accessor is what shows the new default.
name.Accessor(nameAccessor)
return nil
})
product := huh.NewSelect[string]().
Key("product").
Title("Server software").
Description("Choose the server implementation to create.").
Options(createProductOptions(values.Product)...).
Value(&values.Product)
var minecraftVersions atomic.Value
go func() {
releases, err := router.MinecraftReleases(ctx)
if err != nil {
return
}
versions := make([]string, 0, min(len(releases), 100))
for _, release := range releases {
versions = append(versions, release.String())
if len(versions) == 100 {
break
}
}
minecraftVersions.Store(versions)
}()
minecraft := huh.NewInput().
Key("minecraft").
Title("Minecraft version").
Placeholder("1.21.4").
Description("A release or snapshot ID such as 1.21.4, or release, snapshot, or latest. Start typing for known versions.").
Value(&values.Minecraft).
SuggestionsFunc(func() []string {
versions, _ := minecraftVersions.Load().([]string)
return versions
}, &values.Minecraft).
Validate(func(value string) error {
return validateCreateMinecraft(value, createProduct(values.Product))
})
coreVersion := huh.NewInput().
Key("core-version").
Title("Server software version").
PlaceholderFunc(func() string {
return createCoreVersionPlaceholder(values.Product)
}, &values.Product).
DescriptionFunc(func() string {
return createCoreVersionDescription(values.Product)
}, &values.Product).
Value(&values.CoreVersion)
groups := []*huh.Group{
huh.NewGroup(directory, name, product, minecraft).
Title("New server").
Description("Choose where the server lives, what it is called, and what it runs."),
huh.NewGroup(coreVersion).
Title("Server version").
Description("Pick an exact build or let Lucy choose the latest.").
WithHideFunc(func() bool {
return values.Product == createProductMinecraft
}),
}
summary := huh.NewNote().
Title("Review").
DescriptionFunc(func() string {
return createFormSummary(values, base)
}, &values)
if values.InstallChosen {
summary = summary.Next(true).NextLabel("Create")
groups = append(groups, huh.NewGroup(summary).
Title("Finish").
Description("Review the choices, then create the server."))
} else {
install := huh.NewConfirm().
Key("install").
Title("Install the server now?").
Description("Not yet writes only lucy.yaml and lucy-lock.yaml; run lucy install later.").
Affirmative("Install").
Negative("Not yet").
Value(&values.Install)
if values.Java {
install = install.Validate(func(install bool) error {
if !install {
return errors.New("--java needs installation; choose Install")
}
return nil
})
}
groups = append(groups, huh.NewGroup(summary, install).
Title("Finish").
Description("Confirm the installation choice to create the server."))
}
replaceGroup := huh.NewGroup(
huh.NewConfirm().
Key("replace").
Title("Replace existing manifest?").
DescriptionFunc(func() string {
target, err := operation.InspectCreateTarget(
resolveInvocationPath(base, values.Directory),
)
if err != nil || !target.ManifestExists {
return ""
}
if target.DeclaredPackages == 0 {
return "The existing lucy.yaml will be replaced."
}
return "The existing lucy.yaml declares " +
createPackageCountLabel(target.DeclaredPackages) +
". Installation may remove files those packages own."
}, &values.Directory).
Affirmative("Replace").
Negative("Choose another directory").
Value(&values.Force).
Validate(func(replace bool) error {
if !replace {
return errors.New("choose another directory or confirm replacement")
}
return nil
}),
).
Title("Existing manifest").
Description("Confirm before replacing the existing lucy.yaml.").
WithHideFunc(func() bool {
if forceFromFlag {
return true
}
target, err := operation.InspectCreateTarget(
resolveInvocationPath(base, values.Directory),
)
return err != nil || !target.ManifestExists
})
groups = append([]*huh.Group{groups[0], replaceGroup}, groups[1:]...)
form := huh.NewForm(groups...).
WithInput(cmd.InOrStdin()).
WithOutput(cmd.ErrOrStderr())
if err := NormalizePromptError(ctx, form.RunWithContext(ctx)); err != nil {
return createChoices{}, err
}
values.Directory = strings.TrimSpace(values.Directory)
if values.Directory == "" {
values.Directory = "."
}
values.Name = strings.TrimSpace(values.Name)
if values.Name == "" {
values.Name = defaultCreateName(values.Directory, base)
}
values.Minecraft = strings.TrimSpace(values.Minecraft)
values.CoreVersion = strings.TrimSpace(values.CoreVersion)
if values.Product == createProductMinecraft {
values.CoreVersion = ""
}
return values, nil
}
// createProductOptions lists what the wizard offers. A product that flags
// settled outside that list stays offered so the flag survives the form.
func createProductOptions(selected string) []huh.Option[string] {
options := []huh.Option[string]{
huh.NewOption("Minecraft (vanilla)", createProductMinecraft),
huh.NewOption("Fabric", string(serverpkg.ProductFabric)),
huh.NewOption("Forge", string(serverpkg.ProductForge)),
huh.NewOption("NeoForge", string(serverpkg.ProductNeoForge)),
}
for _, option := range options {
if option.Value == selected {
return options
}
}
if selected == "" || selected == createProductMinecraft {
return options
}
return append(options, huh.NewOption(createProductTitle(selected), selected))
}
func createProductTitle(product string) string {
if parsed, known := serverpkg.ParseProduct(product); known {
return parsed.Title()
}
if product == "" {
return "Minecraft"
}
runes := []rune(product)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func createCoreVersionDescription(product string) string {
switch product {
case string(serverpkg.ProductPaper):
return "Paper build number such as 232, or stable / any."
case string(serverpkg.ProductPurpur):
return "Purpur build number, latest, or any."
case string(serverpkg.ProductFabric):
return "Fabric loader version such as 0.16.9, or any for the latest."
case string(serverpkg.ProductForge):
return "Forge version such as 47.3.0, or any for the latest."
case string(serverpkg.ProductNeoForge):
return "NeoForge version such as 21.1.233, or any for the latest."
default:
return "Leave empty to let Lucy choose the latest version."
}
}
func createCoreVersionPlaceholder(product string) string {
switch product {
case string(serverpkg.ProductPaper):
return "232"
case string(serverpkg.ProductPurpur):
return "latest"
case string(serverpkg.ProductFabric):
return "0.16.9"
case string(serverpkg.ProductForge):
return "47.3.0"
case string(serverpkg.ProductNeoForge):
return "21.1.233"
default:
return "any"
}
}
func createFormSummary(values createChoices, base string) string {
serverVersion := strings.TrimSpace(values.CoreVersion)
if values.Product == createProductMinecraft {
serverVersion = "the Minecraft version"
} else if serverVersion == "" {
serverVersion = "any (latest)"
}
server := createProductTitle(values.Product)
lines := []string{
"Name: " + values.Name,
"Directory: " + displayCreateLocation(values.Directory, base),
"Server: " + server + " for Minecraft " + values.Minecraft,
"Server version: " + serverVersion,
}
install := "not yet"
if values.Install {
install = "yes"
}
lines = append(lines, "Install after creation: "+install)
if target, err := operation.InspectCreateTarget(
resolveInvocationPath(base, values.Directory),
); err == nil {
if target.NonEmpty {
lines = append(lines, "Warning: the directory is not empty.")
}
if target.ManifestExists && values.Force {
replacement := "Existing lucy.yaml will be replaced."
if target.DeclaredPackages > 0 {
replacement = "Existing lucy.yaml will be replaced; it declares " +
createPackageCountLabel(target.DeclaredPackages) + "."
}
lines = append(lines, replacement)
}
}
return strings.Join(lines, "\n")
}
func describeCreateDirectory(path string, force bool, base string) string {
usingCurrent := strings.TrimSpace(path) == ""
if usingCurrent {
path = "."
}
target, err := operation.InspectCreateTarget(resolveInvocationPath(base, path))
if err != nil {
return err.Error()
}
prefix := ""
if usingCurrent {
prefix = "Using the current directory. "
}
if !target.Exists {
return prefix + "This directory will be created."
}
if !target.NonEmpty {
return prefix + "This directory is empty."
}
description := prefix + "Warning: this directory is not empty"
if len(target.Entries) > 0 {
description += " (" + strings.Join(target.Entries, ", ")
if len(target.Entries) == 5 {
description += ", …"
}
description += ")"
}
description += ". Existing files stay in place; installation may conflict."
if target.ManifestExists {
if force {
description += " An existing lucy.yaml will be replaced."
if target.DeclaredPackages > 0 {
description += " It declares " + createPackageCountLabel(target.DeclaredPackages) +
"; installation may remove files those packages own."
}
} else {
description += " lucy.yaml already exists; confirm replacement on the next step."
}
}
return description
}
func createPackageCountLabel(count int) string {
if count == 1 {
return "1 package"
}
return fmt.Sprintf("%d packages", count)
}
func validateCreateDirectory(path, base string) error {
if strings.TrimSpace(path) == "" {
path = "."
}
_, err := operation.InspectCreateTarget(resolveInvocationPath(base, path))
return err
}
// defaultCreateName names a new server after the directory it occupies. The
// name is not slugged: an unusable directory name leaves the input empty so
// the form asks for an explicit name.
func defaultCreateName(directory, base string) string {
target, err := operation.InspectCreateTarget(
resolveInvocationPath(base, directory),
)
if err != nil {
return ""
}
name, err := document.NormalizeName(filepath.Base(target.Path))
if err != nil {
return ""
}
return name
}
func validateCreateName(value string) error {
_, err := document.NormalizeName(value)
return err
}
package cli
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/mclucy/lucy/bootstrap"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/internal/shell"
"github.com/mclucy/lucy/operation"
"github.com/mclucy/lucy/report"
serverpkg "github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/terminal/style"
"github.com/mclucy/lucy/version"
"github.com/spf13/cobra"
)
// createOutput reuses install progress and warning plumbing, but owns the
// create summary and failure wording.
type createOutput struct {
*installOutput
}
// createJSON is the machine-readable result of one creation.
var createJSON JSONResult[*operation.CreateResult]
func newCreateOutput(cmd *cobra.Command, inv *Invocation) *createOutput {
return &createOutput{installOutput: newInstallOutput(cmd, inv)}
}
// Render prints one creation outcome. JSON goes to stdout; human output goes
// to stderr, like every other mutating command.
func (o *createOutput) Render(result *operation.CreateResult) error {
if result == nil {
return nil
}
result.WarningMessages = append(result.WarningMessages, o.Warnings()...)
if o.JSON() {
return createJSON.Write(o.Output, result)
}
lines := createSummaryLines(result, o.inv.Theme, o.inv.Directory)
o.renderer.Println(append([]string{""}, lines...)...)
o.Close()
for _, warning := range result.Warnings {
o.Warn(warning)
}
for _, diagnostic := range result.Diagnostics {
o.Warn(fmt.Errorf("%s: %s", diagnostic.Path, diagnostic.Message))
}
return nil
}
// Failure adds the state that a create failure leaves behind. The shared
// install presenter explains the operation error itself.
func (o *createOutput) Failure(err error) error {
if err == nil {
return nil
}
if resolveErr, ok := errors.AsType[*operation.CreateResolveError](err); ok {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return &report.Detail{
Title: "creation cancelled",
Lines: []string{"No manifest was written."},
Cause: err,
}
}
detail := &report.Detail{
Title: "could not create the " + createProductTitle(string(resolveErr.Product)) + " server",
Lines: []string{
"Directory: " + displayCreateLocation(resolveErr.Workspace, o.inv.Directory),
"Minecraft: " + versionLabel(resolveErr.Minecraft),
"Server version: " + versionLabel(resolveErr.Selector),
},
Cause: err,
}
if inner := createErrorDetail(o, resolveErr.Err); inner != nil {
detail.Lines = append(detail.Lines, inner.Lines...)
detail.Hint = inner.Hint
} else {
detail.Lines = append(detail.Lines, resolveErr.Err.Error())
}
if detail.Hint == "" {
switch {
case resolveErr.Product == serverpkg.ProductPaper:
detail.Hint = "Paper versions are build numbers; try 232, stable, or any."
case resolveErr.Product == serverpkg.ProductPurpur:
detail.Hint = "Purpur versions are build numbers or latest."
default:
detail.Hint = "Check the server software version and the Minecraft version, then retry."
}
}
return detail
}
if publishErr, ok := errors.AsType[*operation.CreatePublishError](err); ok {
detail := &report.Detail{
Title: "could not write the server directory",
Lines: []string{"Directory: " + displayCreatePath(publishErr.Workspace, o.inv.Directory)},
Cause: err,
}
if publishErr.ManifestWritten {
detail.Lines = append(detail.Lines, "lucy.yaml was written, but lucy-lock.yaml was not.")
detail.Hint = "Run " + createInstallCommand(publishErr.Workspace, o.inv.Directory) + " to finish resolving the lock."
} else {
detail.Lines = append(detail.Lines, "No manifest or lock was written.")
detail.Hint = "Check that the directory is writable and try again."
}
detail.Lines = append(detail.Lines, publishErr.Err.Error())
return detail
}
if registrationErr, ok := errors.AsType[*operation.CreateRegistrationError](err); ok {
return &report.Detail{
Title: "the server directory was created but it was not registered",
Lines: []string{
"Directory: " + displayCreateLocation(registrationErr.Workspace, o.inv.Directory),
"lucy.yaml and lucy-lock.yaml were written.",
registrationErr.Err.Error(),
},
Hint: "Fix the server registry or the workspace name, then run lucy install again.",
Cause: err,
}
}
if installErr, ok := errors.AsType[*operation.CreateInstallError](err); ok {
detail := &report.Detail{
Title: "the server directory was created but installation failed",
Lines: []string{
"Directory: " + displayCreateLocation(installErr.Workspace, o.inv.Directory),
"lucy.yaml and lucy-lock.yaml were written.",
},
Cause: err,
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
detail.Title = "installation interrupted"
detail.Lines = append(detail.Lines, "The server directory was created; installation did not finish.")
}
if _, committed := errors.AsType[*install.CommittedError](installErr.Err); committed {
detail.Title = "the server directory was created but provisioning was interrupted"
detail.Lines = append(detail.Lines, "The installation was committed; file provisioning did not finish.")
}
if inner := createErrorDetail(o, installErr.Err); inner != nil {
detail.Lines = append(detail.Lines, inner.Lines...)
detail.Hint = inner.Hint
} else {
detail.Lines = append(detail.Lines, installErr.Err.Error())
}
if detail.Hint == "" {
detail.Hint = "Run " + createInstallCommand(installErr.Workspace, o.inv.Directory) + " to finish."
}
message := installErr.Err.Error()
switch {
case errors.Is(installErr.Err, bootstrap.ErrNoJava):
detail.Hint = "Run " + createInstallCommand(installErr.Workspace, o.inv.Directory) +
" --java to install the Java runtime the server asks for."
case strings.Contains(message, "unowned or modified output conflict"):
detail.Hint = "Move the existing file out of the way, or run lucy init to adopt the existing server."
case strings.Contains(message, "stop the running Minecraft server"):
detail.Hint = "Stop the server, then run " + createInstallCommand(installErr.Workspace, o.inv.Directory) + "."
}
return detail
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return &report.Detail{
Title: "creation cancelled",
Lines: []string{"No manifest was written."},
Cause: err,
}
}
return o.installOutput.Failure(err)
}
func createSummaryLines(
result *operation.CreateResult,
theme style.Theme,
base string,
) []string {
path := displayCreateLocation(result.Workspace, base)
title := createProductTitle(string(result.Server.Product))
minecraft := string(result.Server.Minecraft)
coreVersion := string(result.Server.Version)
if coreVersion == "" {
coreVersion = minecraft
}
var headline string
custom := result.Manifest != nil && result.Manifest.Server.Core.Repository != ""
switch {
case result.Server.Product == serverpkg.ProductMinecraft:
headline = theme.Success("✓") + " Created a Minecraft " + minecraft + " server"
case custom:
headline = theme.Success("✓") + " Created " + title + " " +
coreVersion + " for Minecraft " + minecraft
default:
headline = theme.Success("✓") + " Created " + title + " " + coreVersion +
" for Minecraft " + minecraft
}
if path != "" {
headline += " in " + path
}
written := "Wrote lucy.yaml"
if result.ReplacedManifest {
written = "Replaced lucy.yaml"
}
if result.PublishedLock {
written += " and lucy-lock.yaml"
} else {
written += " (lucy-lock.yaml unchanged)"
}
lines := []string{
headline,
theme.Muted(" " + written),
}
if result.Installed {
files := result.Files.Created + result.Files.Replaced
switch {
case files > 0:
lines = append(lines, theme.Muted(" Installed "+fileCountLabel(files)))
default:
lines = append(lines, theme.Muted(" Server files are current"))
}
if result.Files.Removed > 0 {
lines = append(lines, theme.Muted(" Removed "+fileCountLabel(result.Files.Removed)))
}
} else {
lines = append(
lines,
" "+theme.Note("Next:")+" "+createInstallCommand(result.Workspace, base),
)
}
return lines
}
func createInstallCommand(workspace, base string) string {
path := displayCreatePath(workspace, base)
if path == "." || path == "" {
return "lucy install"
}
if !filepath.IsAbs(path) {
path = "./" + path
}
return "cd " + shell.Quote(path) + " && lucy install"
}
func fileCountLabel(count int) string {
if count == 1 {
return "1 file"
}
return fmt.Sprintf("%d files", count)
}
func versionLabel(value version.Version) string {
text := strings.TrimSpace(value.String())
if text == "" {
return "any"
}
return text
}
// createErrorDetail uses the install presenter's richer failure mapping, then
// falls back to the generic resolution detail.
func createErrorDetail(o *createOutput, err error) *report.Detail {
if inner := o.installOutput.Failure(err); inner != nil {
if detail, ok := errors.AsType[*report.Detail](inner); ok {
return detail
}
}
return errorDetail(err)
}
package cli
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
serverpkg "github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/routing"
versionpkg "github.com/mclucy/lucy/version"
"github.com/mclucy/lucy/workspace"
"github.com/mclucy/lucy/workspace/discovery"
"github.com/mclucy/lucy/workspace/identify"
)
type GraphOrigin int
const (
OriginLock GraphOrigin = iota
OriginLive
)
func (ds GraphOrigin) String() string {
switch ds {
case OriginLock:
return "lock file"
case OriginLive:
return "live probe"
default:
return "unknown"
}
}
// ServerEnvironment is the resolved server identity a graph belongs to.
// Values come from the manifest or lock when either is readable; a live
// probe fills the gaps when no lock exists.
type ServerEnvironment struct {
Core string
GameVersion string
Ecosystem catalog.Ecosystem
EcosystemVersion string
}
// LoadDependencyData builds the dependency graph from the lock file, falling
// back to a live workspace observation when the lock is missing or invalid.
// forceLive skips the lock file entirely.
//
// It also reports the server environment, derived from the manifest or lock
// when available and from the workspace probe otherwise.
func LoadDependencyData(
ctx context.Context,
workDir string,
forceLive bool,
lookup func(context.Context, upstream.ArtifactFingerprint) routing.ArtifactLookup,
output *Output,
) (
*DependencyGraph,
GraphOrigin,
ServerEnvironment,
error,
) {
var (
env ServerEnvironment
lock *document.Lock
origin = OriginLock
)
if err := ctx.Err(); err != nil {
return nil, 0, env, err
}
if !forceLive {
doc, err := document.LockFile(workDir).Read()
if err == nil {
lock = doc
env = environmentFromLock(doc)
}
// Lock file missing or invalid — fall through to probe.
}
if lock != nil {
graph, err := BuildGraphFromLock(lock)
if err != nil {
return nil, 0, env, fmt.Errorf(
"failed to build graph from lock: %w",
err,
)
}
if err := ctx.Err(); err != nil {
return nil, 0, env, err
}
return graph, origin, env, nil
}
origin = OriginLive
info, _ := identify.Packages(
ctx,
workspace.Observe(workDir, discovery.Request{}),
lookup,
)
if err := ctx.Err(); err != nil {
return nil, 0, env, err
}
reportObservationFindings(info, output)
graph, err := BuildGraphFromObservation(info)
if err != nil {
return nil, 0, env, fmt.Errorf("failed to build graph from observation: %w", err)
}
if err := ctx.Err(); err != nil {
return nil, 0, env, err
}
return graph, origin, environmentFromWorkspace(info, env), nil
}
// reportObservationFindings renders each consumed observation once.
func reportObservationFindings(observed *workspace.Workspace, output *Output) {
if observed == nil {
return
}
for _, warning := range observed.Warnings {
output.Warn(warning)
}
reportDiscoveryFindings(&observed.Discovery, output)
}
// reportDiscoveryFindings presents one discovery result. A resolved layout
// never reports a failure: automatic choices are warnings, and unmanaged or
// alternative entry points stay informational.
func reportDiscoveryFindings(observed *discovery.Result, output *Output) {
if observed == nil {
return
}
selection := observed.Selection
resolved := selection.IsValid()
automatic := selection.Source == discovery.SourcePreferred
unresolvedReported := false
unreadableReported := map[string]bool{}
for finding := range observed.Findings() {
switch finding.Kind {
case discovery.FindingAutomaticSelection:
output.Warn(automaticSelectionMessage(observed, finding))
case discovery.FindingUnreadableFile:
key := unreadableFindingKey(finding)
if unreadableReported[key] {
continue
}
unreadableReported[key] = true
output.Warn(unreadableFindingError(finding))
case discovery.FindingAlternativeEntryPoint,
discovery.FindingUnmanagedInstallation:
message := entryPointFindingMessage(finding)
if output.JSON() {
output.Warn(errors.New(message))
} else {
output.Info(message)
}
case discovery.FindingMissingDeclaredInstallation:
output.Warn(errors.New(
"declared server installation is missing; run lucy install --locked",
))
case discovery.FindingOwnershipError:
output.Warn(fmt.Errorf(
"cannot verify ownership of %s: %w",
finding.Path,
finding.Cause,
))
case discovery.FindingModifiedFile:
output.Warn(fmt.Errorf(
"managed file %s was modified; it stays unmanaged",
finding.Path,
))
case discovery.FindingReceiptError:
if finding.Cause != nil {
output.Warn(fmt.Errorf(
"cannot verify installation ownership: %w",
finding.Cause,
))
} else {
output.Warn(errors.New("cannot verify installation ownership"))
}
case discovery.FindingInvalidIdentity:
output.Warn(fmt.Errorf(
"cannot identify the server in %s: %v",
finding.Path,
finding.Cause,
))
case discovery.FindingConflictingIdentities,
discovery.FindingAmbiguousInstallations:
if resolved || automatic || unresolvedReported {
continue
}
message := unresolvedRuntimeMessage(observed, finding)
if output.JSON() {
output.Warn(message)
} else {
output.Error(message)
}
unresolvedReported = true
}
}
}
// unreadableFindingKey identifies one underlying read failure. Recognizers
// can inspect the same artifact, so identical path and cause pairs collapse.
func unreadableFindingKey(finding discovery.Finding) string {
cause := ""
if finding.Cause != nil {
cause = finding.Cause.Error()
}
return finding.Path + "\x00" + cause
}
func unreadableFindingError(finding discovery.Finding) error {
if finding.Cause != nil {
return fmt.Errorf("cannot read %s: %w", finding.Path, finding.Cause)
}
return fmt.Errorf("cannot read %s", finding.Path)
}
// unreadableFindingLines presents each underlying read failure once for a
// command failure detail.
func unreadableFindingLines(result *discovery.Result) []string {
if result == nil {
return nil
}
var lines []string
seen := map[string]bool{}
for _, finding := range result.Diagnostics {
if finding.Kind != discovery.FindingUnreadableFile {
continue
}
key := unreadableFindingKey(finding)
if seen[key] {
continue
}
seen[key] = true
if finding.Cause != nil {
lines = append(
lines,
fmt.Sprintf("%s: %v", finding.Path, finding.Cause),
)
} else {
lines = append(lines, finding.Path)
}
}
return lines
}
func entryPointFindingMessage(finding discovery.Finding) string {
label := "unmanaged entry point"
if finding.Kind == discovery.FindingUnmanagedInstallation {
label = "unmanaged server entry point"
}
return fmt.Sprintf("%s %s; left unchanged", label, finding.Path)
}
// automaticSelectionMessage names the chosen entry point and every competing
// entry point. The choice is read-only; the message suggests recording it.
func automaticSelectionMessage(
observed *discovery.Result,
finding discovery.Finding,
) error {
selected := finding.Path
if selected == "" && observed.Selection.EntryPoint != nil {
selected = observed.Selection.EntryPoint.Path
}
competitors := finding.Paths
if len(competitors) == 0 {
competitors = competingEntryPoints(observed, selected)
}
message := fmt.Sprintf(
"multiple server runtimes found; using %s automatically",
selected,
)
if len(competitors) > 0 {
message += "; competing entry points: " + strings.Join(competitors, ", ")
}
message += "; run lucy init --entry-point '<path>' to record a choice"
return errors.New(message)
}
func unresolvedRuntimeMessage(
observed *discovery.Result,
finding discovery.Finding,
) error {
paths := finding.Paths
if len(paths) == 0 {
paths = competingEntryPoints(observed, "")
}
if len(paths) == 0 {
return errors.New("no unambiguous server runtime was found")
}
commands := make([]string, 0, len(paths))
for _, path := range paths {
commands = append(
commands,
"lucy init --entry-point '"+strings.ReplaceAll(path, "'", "'\\''")+"'",
)
}
return fmt.Errorf(
"cannot choose between server runtimes; select one with %s",
strings.Join(commands, " or "),
)
}
// competingEntryPoints is a fallback for findings that carry no paths. It
// lists every entry point except the selected one in a stable order.
func competingEntryPoints(
result *discovery.Result,
selected string,
) []string {
var paths []string
seen := map[string]bool{}
for i := range result.Installations {
for file := range result.Installations[i].EntryPoints() {
if file.Path == "" || file.Path == selected || seen[file.Path] {
continue
}
seen[file.Path] = true
paths = append(paths, file.Path)
}
}
slices.Sort(paths)
return paths
}
// environmentFromLock reads the server identity from the lock's explicit
// fields. The lock always records exact versions, so nothing is inferred.
func environmentFromLock(lock *document.Lock) ServerEnvironment {
return ServerEnvironment{
Core: lock.Server.Product.String(),
GameVersion: lock.Server.Minecraft.String(),
Ecosystem: ecosystemFromLock(lock),
EcosystemVersion: ecosystemVersionFromLock(lock),
}
}
// ecosystemFromLock reports the ecosystem that scopes the server runtime.
// Server packages come first; the bootstrap inputs name the loader when the
// runtime has no packages of its own. It returns EcosystemUnspecified when
// the evidence disagrees.
func ecosystemFromLock(lock *document.Lock) catalog.Ecosystem {
var found catalog.Ecosystem
for _, pkg := range lock.Packages {
if pkg.Environment != serverpkg.EnvironmentServer ||
pkg.Ecosystem == catalog.EcosystemUnspecified {
continue
}
if found != catalog.EcosystemUnspecified && found != pkg.Ecosystem {
return catalog.EcosystemUnspecified
}
found = pkg.Ecosystem
}
if found != catalog.EcosystemUnspecified {
return found
}
for _, input := range lock.Server.Inputs {
for _, module := range input.Modules {
if module.Ecosystem == catalog.EcosystemUnspecified {
continue
}
if found != catalog.EcosystemUnspecified && found != module.Ecosystem {
return catalog.EcosystemUnspecified
}
found = module.Ecosystem
}
}
return found
}
func ecosystemVersionFromLock(lock *document.Lock) string {
ecosystem := ecosystemFromLock(lock)
if ecosystem == catalog.EcosystemUnspecified {
return ""
}
for _, input := range lock.Server.Inputs {
for _, module := range input.Modules {
if module.Ecosystem == ecosystem && module.Version != "" {
return module.Version.String()
}
}
}
return ""
}
// environmentFromWorkspace fills the environment gaps a lock could not
// supply by observing the live workspace. Values already known win.
func environmentFromWorkspace(ws *workspace.Workspace, env ServerEnvironment) ServerEnvironment {
if ws == nil {
return env
}
if env.Core == "" || env.GameVersion == "" ||
env.Ecosystem == catalog.EcosystemUnspecified {
if doc, err := document.ManifestFile(ws.ServerDir).Read(); err == nil {
if env.Core == "" {
env.Core = doc.Server.Core.Product.String()
}
if env.GameVersion == "" {
env.GameVersion = doc.Server.Minecraft.String()
}
}
}
server := ws.Runtime()
if server == nil {
return env
}
if env.GameVersion == "" {
if version := server.MinecraftVersion().String(); version != "" {
env.GameVersion = version
}
}
if env.Core == "" {
env.Core = server.Product().String()
}
if env.Ecosystem == catalog.EcosystemUnspecified {
env.Ecosystem = server.Ecosystem()
}
if env.EcosystemVersion == "" {
ecosystem := server.Ecosystem()
for _, component := range selectedRuntimeComponents(server) {
if component.Ecosystem != ecosystem {
continue
}
switch component.Version {
case "", versionpkg.None, versionpkg.Unknown, versionpkg.Any,
versionpkg.Stable, versionpkg.Beta:
continue
default:
env.EcosystemVersion = component.Version.String()
}
}
}
return env
}
package cli
import (
"fmt"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/spf13/cobra"
)
// deinitDocument is the machine-readable outcome of one deinitialization.
type deinitDocument struct {
Removed []string `json:"removed"`
Warnings []string `json:"warnings,omitempty"`
}
var deinitJSON JSONResult[deinitDocument]
func NewDeinitCommand(contracts *JSONContracts, inv *Invocation, deps *Dependencies) *cobra.Command {
deinitCmd := &cobra.Command{
Use: "deinit",
Short: help.DeinitShort,
Long: help.DeinitLong,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return actionDeinit(cmd, inv, deps)
},
}
deinitJSON.Bind(contracts, deinitCmd)
return deinitCmd
}
func actionDeinit(cmd *cobra.Command, inv *Invocation, deps *Dependencies) error {
output := NewOperationOutput(cmd, inv)
result, err := deps.Operations.Deinitialize(inv.Directory)
if err != nil {
return err
}
for _, warning := range result.Warnings {
output.Warn(warning)
}
document := deinitDocument{Removed: result.Removed, Warnings: output.Warnings()}
if output.JSON() {
return deinitJSON.Write(output, document)
}
out := output.Human()
for _, name := range result.Removed {
if _, err := fmt.Fprintf(out, "removed %s\n", name); err != nil {
return err
}
}
_, err = fmt.Fprintln(out, "Lucy no longer manages this server")
return err
}
package cli
import (
"github.com/mclucy/lucy/internal/cli/help"
"github.com/spf13/cobra"
)
// Doctor is advertised but not implemented. Its file and Java diagnostics have
// no agreed contract yet; see https://github.com/mclucy/lucy/issues/172.
func NewDoctorCommand() *cobra.Command {
return &cobra.Command{
Use: "doctor",
Short: help.DoctorShort,
RunE: NotImplementedRun,
}
}
package cli
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/mclucy/lucy/resolve"
versionpkg "github.com/mclucy/lucy/version"
)
const (
maxFactChainTypes = 12
maxFactFailures = 5
maxFactRejections = 8
maxFactTextBytes = 256
maxFactDepth = 32
)
// genericErrorTypes name wrapping errors that carry no fact of their own.
// They stay out of the chain so a failure document does not grow a key for
// every layer the command boundary happens to add.
var genericErrorTypes = map[string]bool{
"report.Detail": true,
"errors.errorString": true,
"errors.joinError": true,
"fmt.wrapError": true,
}
// FailureFacts returns the machine-readable evidence behind a failure: the
// typed errors on the way to the root cause, and the facts they carry. It
// walks the whole error tree, including joined children, because errors.As
// only reaches the first match.
//
// The result holds identifiers, enum names, and counts. It never holds free
// text other than the rejection details the resolver already formats from
// module identifiers, and it returns nil when the tree holds no typed error,
// so ordinary failures keep an unchanged error document.
func FailureFacts(err error) map[string]any {
if err == nil {
return nil
}
collector := factCollector{seenTypes: map[string]bool{}}
collector.walk(err, 0)
return collector.facts()
}
type factCollector struct {
types []string
seenTypes map[string]bool
failures []failureFacts
attempts int
}
type failureFacts struct {
key string
project string
provider string
projectID string
minecraft string
ecosystem string
candidates int
aborted bool
occurrences int
rejections []rejectionFacts
}
type rejectionFacts struct {
kind string
detail string
count int
firstVersion string
lastVersion string
requiredBy string
dependency string
constraint string
selected string
}
func (c *factCollector) walk(err error, depth int) {
if err == nil || depth > maxFactDepth {
return
}
c.addType(fmt.Sprintf("%T", err))
switch node := err.(type) {
case *resolve.Failure:
c.addFailure(node)
case *resolve.SearchLimitError:
if node.Attempts > c.attempts {
c.attempts = node.Attempts
}
}
switch node := err.(type) {
case interface{ Unwrap() []error }:
for _, child := range node.Unwrap() {
c.walk(child, depth+1)
}
case interface{ Unwrap() error }:
c.walk(node.Unwrap(), depth+1)
}
}
func (c *factCollector) addType(name string) {
name = strings.TrimPrefix(name, "*")
if name == "" || genericErrorTypes[name] || c.seenTypes[name] || len(c.types) >= maxFactChainTypes {
return
}
c.seenTypes[name] = true
c.types = append(c.types, name)
}
func (c *factCollector) addFailure(failure *resolve.Failure) {
if failure == nil {
return
}
key := failureKey(failure)
for index := range c.failures {
if c.failures[index].key == key {
c.failures[index].occurrences++
return
}
}
if len(c.failures) >= maxFactFailures {
return
}
entry := failureFacts{
key: key,
project: factText(failure.Project),
provider: string(failure.Provider),
projectID: factText(failure.ProjectID),
minecraft: factVersion(failure.Minecraft),
ecosystem: string(failure.Ecosystem),
candidates: failure.Candidates,
aborted: failure.Aborted,
occurrences: 1,
}
for _, group := range failure.Groups {
if len(entry.rejections) >= maxFactRejections {
break
}
entry.rejections = append(entry.rejections, rejectionFacts{
kind: string(group.Kind),
detail: factText(group.Detail),
count: group.Count,
firstVersion: factText(group.FirstVersion),
lastVersion: factText(group.LastVersion),
requiredBy: factText(group.RequiredBy),
dependency: factText(group.Dependency),
constraint: factText(group.Constraint),
selected: factText(group.Selected),
})
}
c.failures = append(c.failures, entry)
}
func failureKey(failure *resolve.Failure) string {
parts := []string{
failure.Project,
failure.ProjectID,
fmt.Sprint(failure.Aborted),
}
for _, group := range failure.Groups {
parts = append(parts, string(group.Kind), group.Detail)
}
return strings.Join(parts, "\x00")
}
func (c *factCollector) facts() map[string]any {
facts := map[string]any{}
if len(c.types) > 0 {
facts["chain"] = c.types
}
if c.attempts > 0 {
facts["search_limit"] = map[string]any{"attempts": c.attempts}
}
if len(c.failures) > 0 {
entries := make([]map[string]any, 0, len(c.failures))
for _, failure := range c.failures {
entries = append(entries, failure.entry())
}
facts["failures"] = entries
}
if len(facts) == 0 {
return nil
}
return facts
}
func (f failureFacts) entry() map[string]any {
entry := map[string]any{}
if f.project != "" {
entry["project"] = f.project
}
if f.projectID != "" {
entry["project_id"] = f.projectID
}
if f.provider != "" {
entry["provider"] = f.provider
}
if f.ecosystem != "" {
entry["ecosystem"] = f.ecosystem
}
if f.minecraft != "" {
entry["minecraft"] = f.minecraft
}
if f.candidates > 0 {
entry["candidates"] = f.candidates
}
if f.aborted {
entry["aborted"] = true
}
if f.occurrences > 1 {
entry["occurrences"] = f.occurrences
}
if len(f.rejections) > 0 {
rejections := make([]map[string]any, 0, len(f.rejections))
for _, rejection := range f.rejections {
rejections = append(rejections, rejection.entry())
}
entry["rejections"] = rejections
}
return entry
}
func (r rejectionFacts) entry() map[string]any {
entry := map[string]any{"kind": r.kind}
if r.detail != "" {
entry["detail"] = r.detail
}
if r.count > 0 {
entry["count"] = r.count
}
if r.firstVersion != "" {
entry["first_version"] = r.firstVersion
}
if r.lastVersion != "" {
entry["last_version"] = r.lastVersion
}
if r.requiredBy != "" {
entry["required_by"] = r.requiredBy
}
if r.dependency != "" {
entry["dependency"] = r.dependency
}
if r.constraint != "" {
entry["constraint"] = r.constraint
}
if r.selected != "" {
entry["selected"] = r.selected
}
return entry
}
// factVersion renders a version fact, and drops the values that describe no
// target: the zero version and the wildcard both mean the failure was not
// bound to one Minecraft version.
func factVersion(value versionpkg.Version) string {
if value == "" || value == versionpkg.Any {
return ""
}
return factText(value.String())
}
// factText bounds one fact string. Identifiers and versions are far shorter
// than the bound; it only exists so a rejection detail cannot grow the report.
func factText(value string) string {
return truncateFactText(value, maxFactTextBytes)
}
func truncateFactText(value string, limit int) string {
value = strings.TrimSpace(value)
if limit <= 0 || len(value) <= limit {
return value
}
cut := limit
for cut > 0 && !utf8.RuneStart(value[cut]) {
cut--
}
return value[:cut] + "…"
}
package cli
import (
"fmt"
"github.com/spf13/cobra"
)
// NotImplementedRun answers commands whose behavior is not designed yet.
//
// An advertised command must fail loudly instead of succeeding silently;
// silence reads as a completed operation.
func NotImplementedRun(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("%s: not implemented yet", cmd.Name())
}
package cli
import "strconv"
// ExitError carries an explicit process exit status through the command
// boundary. Silent suppresses error rendering for statuses that are not
// failures, such as a search with no matches.
type ExitError struct {
Code int
Err error
Silent bool
}
// ExitWith reports a failure with a specific process exit status.
func ExitWith(code int, err error) error {
return &ExitError{
Code: code,
Err: err,
}
}
// SilentExit reports a status without an error message.
func SilentExit(code int) error {
return &ExitError{
Code: code,
Silent: true,
}
}
func (e *ExitError) Error() string {
if e.Err != nil {
return e.Err.Error()
}
return "exit status " + strconv.Itoa(e.Code)
}
func (e *ExitError) Unwrap() error {
return e.Err
}
func (e *ExitError) ExitStatus() int {
return e.Code
}
func (e *ExitError) SilentExit() bool {
return e.Silent
}
package cli
import (
"github.com/mclucy/lucy/internal/cli/help"
"github.com/spf13/cobra"
)
func NewExportCommand(_ *Invocation) *cobra.Command {
return &cobra.Command{
Use: "export",
Short: help.ExportShort,
RunE: NotImplementedRun,
}
}
package cli
import (
"cmp"
"path/filepath"
"slices"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/workspace"
)
// GraphNode is one node of a resolved package graph. A node is either an
// artifact coordinate ("provider:project", taken from the lock's explicit
// provider fields) or a descriptor module (a module ID declared inside an
// artifact). Identity is always an explicit field; nothing is recovered by
// parsing a display string.
type GraphNode struct {
// ID is the native module ID, or "provider:project" for an artifact.
ID string
Version string
// Provider and ProjectID carry the upstream coordinates of an artifact
// node. They are empty for a native module node, which belongs to the
// artifact recorded in Artifact.
Provider catalog.Provider
ProjectID string
// Requirements lists the manifest requirement references bound to this
// artifact. One artifact can satisfy several declared references.
Requirements []string
// Artifact is the coordinate node that ships this module. It is empty
// for artifact nodes themselves.
Artifact string
Filename string
// Environment scopes the node ("server" or "mcdr") and Ecosystem scopes
// it within that environment. Dependency edges only resolve inside one
// scope.
Environment server.Environment
Ecosystem catalog.Ecosystem
// Module reports whether the node is a native module rather than an
// artifact coordinate.
Module bool
// Root reports a node that an enabled manifest requirement selects.
Root bool
Children []*GraphNode
Parents []*GraphNode
}
type DependencyGraph struct {
// Nodes are keyed by the scoped node key, so identically named modules
// in different environments or ecosystems stay distinct.
Nodes map[string]*GraphNode
// Roots are the artifacts an enabled manifest requirement selects.
Roots []*GraphNode
}
// nodeKey scopes an identity so edges never cross environments or
// ecosystems.
func nodeKey(
environment server.Environment,
ecosystem catalog.Ecosystem,
id string,
) string {
return string(environment) + "|" + string(ecosystem) + "|" + id
}
// artifactCoordinate renders an artifact's explicit provider coordinates.
func artifactCoordinate(provider catalog.Provider, projectID string) string {
return provider.String() + ":" + projectID
}
// BuildGraphFromLock builds the graph from a lock's resolved packages.
//
// Roots are the artifacts selected by an enabled manifest requirement.
// Module dependency edges come from each artifact's native module
// descriptors. The server core and bootstrap inputs are lock inputs, not
// packages, so they never become graph nodes.
func BuildGraphFromLock(lock *document.Lock) (*DependencyGraph, error) {
graph := &DependencyGraph{
Nodes: make(map[string]*GraphNode),
}
dependencies := make(map[string][]catalog.DescriptorDependency)
for _, pkg := range lock.Packages {
coordinate := artifactCoordinate(pkg.Provider, pkg.ProjectID)
key := nodeKey(pkg.Environment, pkg.Ecosystem, coordinate)
node, ok := graph.Nodes[key]
if !ok {
node = &GraphNode{
ID: coordinate,
Version: pkg.Version.String(),
Provider: pkg.Provider,
ProjectID: pkg.ProjectID,
Filename: pkg.Filename,
Environment: pkg.Environment,
Ecosystem: pkg.Ecosystem,
}
graph.Nodes[key] = node
}
for _, module := range pkg.Modules {
moduleKey := nodeKey(pkg.Environment, pkg.Ecosystem, module.ID)
if _, exists := graph.Nodes[moduleKey]; !exists {
graph.Nodes[moduleKey] = &GraphNode{
ID: module.ID,
Version: module.Version.String(),
Artifact: coordinate,
Environment: pkg.Environment,
Ecosystem: pkg.Ecosystem,
Module: true,
Filename: pkg.Filename,
}
}
dependencies[moduleKey] = module.Dependencies
}
for _, binding := range pkg.Requirements {
if binding.Reference != "" &&
!slices.Contains(node.Requirements, binding.Reference) {
node.Requirements = append(node.Requirements, binding.Reference)
}
}
for _, binding := range pkg.Requirements {
if !binding.Enabled {
continue
}
node.Root = true
graph.Roots = append(graph.Roots, node)
break
}
}
// A root artifact hangs its own top-level modules so the tree has
// something to show beneath the requirement.
for _, root := range graph.Roots {
for _, node := range graph.Nodes {
if node.Module && node.Artifact == root.ID &&
node.Environment == root.Environment &&
node.Ecosystem == root.Ecosystem {
root.Children = append(root.Children, node)
node.Parents = append(node.Parents, root)
}
}
}
// Dependency edges stay inside one environment/ecosystem scope.
for _, node := range graph.Nodes {
if !node.Module {
continue
}
for _, dep := range dependencies[node.key()] {
child := resolveDependencyNode(
graph,
node.Environment,
node.Ecosystem,
dep,
)
if child == nil || child == node {
continue
}
if !slices.Contains(child.Parents, node) {
node.Children = append(node.Children, child)
child.Parents = append(child.Parents, node)
}
}
}
slices.SortFunc(graph.Roots, func(a, b *GraphNode) int {
if c := cmp.Compare(a.ID, b.ID); c != 0 {
return c
}
return cmp.Compare(a.key(), b.key())
})
return graph, nil
}
func (n *GraphNode) key() string {
return nodeKey(n.Environment, n.Ecosystem, n.ID)
}
// resolveDependencyNode maps a declared dependency onto a graph node. A
// native dependency names a module ID; when it instead carries upstream
// project coordinates, it resolves to the artifact that ships them.
func resolveDependencyNode(
graph *DependencyGraph,
environment server.Environment,
ecosystem catalog.Ecosystem,
dep catalog.DescriptorDependency,
) *GraphNode {
if dep.ModuleID != "" {
if node, ok := graph.Nodes[nodeKey(environment, ecosystem, dep.ModuleID)]; ok {
return node
}
}
if dep.Provider != catalog.ProviderUnspecified && dep.ProjectID != "" {
coordinate := artifactCoordinate(dep.Provider, dep.ProjectID)
if node, ok := graph.Nodes[nodeKey(environment, ecosystem, coordinate)]; ok {
return node
}
}
if dep.ModuleID == "" && dep.ID != "" {
if node, ok := graph.Nodes[nodeKey(environment, ecosystem, dep.ID)]; ok {
return node
}
}
return nil
}
// BuildGraphFromObservation builds the graph from an observed server's
// packages. A package that no other package lists as a dependency is a root;
// parent-child relationships come from each package's Dependencies field.
func BuildGraphFromObservation(info *workspace.Workspace) (*DependencyGraph, error) {
graph := &DependencyGraph{
Nodes: make(map[string]*GraphNode),
}
for _, p := range info.Packages {
id := p.ID.StringBase()
node := &GraphNode{
ID: id,
Version: p.ID.Version.String(),
Provider: p.ID.Provider,
ProjectID: p.ID.Name.String(),
Filename: filepath.Base(p.Path),
Environment: server.EnvironmentServer,
Ecosystem: p.ID.Ecosystem,
}
graph.Nodes[node.key()] = node
}
// A probe observation has no runtime membership marker, so every node
// scopes to the server environment.
isDependency := make(map[string]bool)
for _, p := range info.Packages {
parentID := p.ID.StringBase()
parent, parentOK := graph.Nodes[nodeKey(
server.EnvironmentServer,
p.ID.Ecosystem,
parentID,
)]
if !parentOK {
continue
}
for _, dep := range p.Dependencies {
depID := dep.ID.StringBase()
depEcosystem := dep.ID.Ecosystem
if depEcosystem == catalog.EcosystemUnspecified {
depEcosystem = p.ID.Ecosystem
}
child, childOK := graph.Nodes[nodeKey(
server.EnvironmentServer,
depEcosystem,
depID,
)]
if !childOK || child == parent {
continue
}
isDependency[child.key()] = true
if slices.Contains(parent.Children, child) {
continue
}
parent.Children = append(parent.Children, child)
child.Parents = append(child.Parents, parent)
}
}
for _, node := range graph.Nodes {
if !isDependency[node.key()] {
node.Root = true
graph.Roots = append(graph.Roots, node)
}
}
slices.SortFunc(graph.Roots, func(a, b *GraphNode) int {
if c := cmp.Compare(a.ID, b.ID); c != 0 {
return c
}
return cmp.Compare(a.key(), b.key())
})
return graph, nil
}
// GetLeaves returns artifact nodes that declare no dependencies. A lock
// artifact is a leaf when none of the modules it ships has a dependency
// child; a probe artifact is a leaf when it has no dependency children.
// Being selected by a manifest requirement does not disqualify a package:
// leaves describe the dependency graph, not removability.
func (g *DependencyGraph) GetLeaves() []*GraphNode {
var leaves []*GraphNode
for _, node := range g.Nodes {
if node.Module || g.artifactHasDependencies(node) {
continue
}
leaves = append(leaves, node)
}
slices.SortFunc(leaves, func(a, b *GraphNode) int {
if c := cmp.Compare(a.ID, b.ID); c != 0 {
return c
}
return cmp.Compare(a.key(), b.key())
})
return leaves
}
// artifactHasDependencies reports whether an artifact node has dependency
// children, either through the modules it ships or, for a probe graph,
// directly through the artifact's own dependency edges.
func (g *DependencyGraph) artifactHasDependencies(node *GraphNode) bool {
shipped := false
for _, candidate := range g.Nodes {
if !candidate.Module || candidate.Artifact != node.ID ||
candidate.Environment != node.Environment ||
candidate.Ecosystem != node.Ecosystem {
continue
}
shipped = true
if len(candidate.Children) > 0 {
return true
}
}
if shipped {
return false
}
return len(node.Children) > 0
}
func (g *DependencyGraph) GetRoots() []*GraphNode {
return g.Roots
}
// TopologicalSort returns nodes in dependency-first order (dependencies
// before dependents). It returns nil if a cycle is detected.
func (g *DependencyGraph) TopologicalSort() []*GraphNode {
outDeg := make(map[string]int, len(g.Nodes))
for id := range g.Nodes {
outDeg[id] = len(g.Nodes[id].Children)
}
queue := make([]string, 0, len(g.Nodes))
for id, deg := range outDeg {
if deg == 0 {
queue = append(queue, id)
}
}
slices.Sort(queue)
result := make([]*GraphNode, 0, len(g.Nodes))
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
result = append(result, g.Nodes[id])
for _, parent := range g.Nodes[id].Parents {
outDeg[parent.key()]--
if outDeg[parent.key()] == 0 {
queue = append(queue, parent.key())
}
}
}
if len(result) != len(g.Nodes) {
return nil
}
return result
}
// OrderedRoots returns the root artifacts in dependency-first order, falling
// back to ID order when the graph has a cycle.
func (g *DependencyGraph) OrderedRoots() []*GraphNode {
sorted := g.TopologicalSort()
if sorted == nil {
ordered := make([]*GraphNode, 0, len(g.Roots))
ordered = append(ordered, g.Roots...)
slices.SortFunc(ordered, func(a, b *GraphNode) int {
if c := cmp.Compare(a.ID, b.ID); c != 0 {
return c
}
return cmp.Compare(a.key(), b.key())
})
return ordered
}
position := make(map[string]int, len(sorted))
for i, node := range sorted {
position[node.key()] = i
}
ordered := make([]*GraphNode, 0, len(g.Roots))
for _, root := range g.Roots {
if _, ok := position[root.key()]; !ok {
return nil
}
ordered = append(ordered, root)
}
slices.SortStableFunc(ordered, func(a, b *GraphNode) int {
return cmp.Compare(position[a.key()], position[b.key()])
})
return ordered
}
package cli
import "github.com/spf13/cobra"
// HelpOnlyAnnotation marks a command that displays its own help when invoked
// without a subcommand.
const HelpOnlyAnnotation = "lucy.io/help-only"
// MarkHelpOnly records that command only displays help when invoked bare. An
// invocation of it performs no work, so telemetry records the event without
// uploading.
func MarkHelpOnly(command *cobra.Command) {
if command.Annotations == nil {
command.Annotations = map[string]string{}
}
command.Annotations[HelpOnlyAnnotation] = "true"
}
// IsHelpOnly reports whether command was marked by MarkHelpOnly.
func IsHelpOnly(command *cobra.Command) bool {
return command != nil && command.Annotations[HelpOnlyAnnotation] != ""
}
package cli
import (
"context"
"errors"
"fmt"
"strings"
"charm.land/huh/v2"
"github.com/mclucy/lucy/config"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/operation"
"github.com/mclucy/lucy/report"
"github.com/mclucy/lucy/workspace/discovery"
"github.com/spf13/cobra"
)
const (
flagInitForceName = "force"
flagInitNameName = "name"
flagInitMCDRVersionName = "mcdr-version"
flagInitEntryPointName = "entry-point"
flagInitNoCommentsName = "no-comments"
)
func NewInitCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
cmd := &cobra.Command{
Use: "init",
Short: help.InitShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionInit(cmd, inv, deps, args)
},
}
initJSON.Bind(contracts, cmd)
cmd.Flags().BoolP(
flagInitForceName,
"f",
false,
"Overwrite an existing manifest without asking",
)
cmd.Flags().String(
flagInitNameName,
"",
"Server name to record (default: the directory name, or the existing name with --force)",
)
cmd.Flags().String(
flagInitMCDRVersionName,
"",
"MCDReforged version to record",
)
cmd.Flags().String(flagInitEntryPointName, "", "Server entry point to adopt when several runtimes are present")
_ = cmd.MarkFlagFilename(flagInitEntryPointName, "jar")
cmd.Flags().Bool(
flagInitNoCommentsName,
false,
"Write generated manifests and configuration without explanatory comments",
)
return cmd
}
// initJSON is the machine-readable result of `lucy init`.
var initJSON JSONResult[*initResult]
// initResult adds workspace configuration publication to the install result
// that other operations share.
type initResult struct {
*install.Result
PublishedConfig bool `json:"published_config"`
}
func actionInit(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) error {
ctx, stop := CommandContext(cmd)
defer stop()
name := flagStringInit(cmd, flagInitNameName)
if cmd.Flags().Changed(flagInitNameName) {
if _, err := document.NormalizeName(name); err != nil {
return fmt.Errorf("--%s: %w", flagInitNameName, err)
}
}
opts := operation.InitOptions{
Name: name,
MCDRVersion: flagStringInit(cmd, flagInitMCDRVersionName),
EntryPoint: flagStringInit(cmd, flagInitEntryPointName),
Force: flagBoolInit(cmd, flagInitForceName),
NoComments: flagBoolInit(cmd, flagInitNoCommentsName),
}
return initializeWorkspace(ctx, cmd, inv, deps, opts)
}
// initializeWorkspace initializes one workspace and owns its presentation.
// Warnings observed during initialization surface on stderr once, failures
// return to the command framework, and success reports the files written from
// the returned document instead of rereading them.
func initializeWorkspace(
ctx context.Context,
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
opts operation.InitOptions,
) error {
output := newInstallOutput(cmd, inv)
defer output.Close()
opts.Trace = output.Trace()
opts.PromptRuntime = func(observed discovery.Result) (string, error) {
output.Close()
return promptRuntime(ctx, cmd, inv, observed)
}
result, err := deps.Operations.Initialize(ctx, inv.Directory, opts)
if err != nil {
return output.Failure(initializationFailure(err, result))
}
output.Close()
for _, warning := range result.Warnings {
output.Warn(warning)
}
reportDiscoveryFindings(&result.Discovery, output.Output)
if output.JSON() {
return initJSON.Write(output.Output, &initResult{
Result: &install.Result{
Schema: install.ResultSchema,
Kind: "install",
Mode: install.ModeResolve,
Server: install.ServerChange{
Product: result.Lock.Server.Product,
Minecraft: result.Lock.Server.Minecraft,
Version: result.Lock.Server.CoreVersion,
},
Packages: []install.PackageChange{},
Phases: []install.Phase{},
TotalPackages: len(result.Lock.Packages),
PublishedManifest: true,
PublishedLock: result.PublishedLock,
WarningMessages: output.Warnings(),
},
PublishedConfig: result.PublishedConfig,
})
}
written := []string{document.ManifestFilename}
if result.PublishedLock {
written = append(written, document.LockFilename)
}
if result.PublishedConfig {
written = append(written, config.WorkspaceFilename)
}
fmt.Fprintf(
output.Human(),
"Wrote %s for Minecraft %s (%s)\n Recorded %s\n",
writtenFilesLabel(written),
result.Document.Server.Minecraft,
result.Document.Server.Core.Product,
packageCountLabel(len(result.Lock.Packages)),
)
return nil
}
// writtenFilesLabel names the files a command wrote in running prose.
func writtenFilesLabel(paths []string) string {
if len(paths) < 2 {
return strings.Join(paths, "")
}
return strings.Join(paths[:len(paths)-1], ", ") + " and " + paths[len(paths)-1]
}
// initializationFailure turns a typed selection failure into an actionable
// report. The commands name installed entry points exactly.
func initializationFailure(err error, result *operation.InitResult) error {
var unreadable []string
if result != nil {
unreadable = unreadableFindingLines(&result.Discovery)
}
var selectionErr *discovery.SelectionError
if !errors.As(err, &selectionErr) {
if len(unreadable) == 0 {
return err
}
return &report.Detail{
Title: err.Error(),
Lines: unreadable,
Cause: err,
}
}
detail := &report.Detail{Cause: err}
switch selectionErr.State {
case discovery.SelectionAmbiguous:
detail.Title = "several server runtimes share this directory"
case discovery.SelectionMissingDeclared:
detail.Title = "the declared server installation is missing"
case discovery.SelectionInvalid:
detail.Title = "the requested entry point is not a recognized server"
default:
return err
}
commands := entryPointCommands(selectionErr.Installations)
detail.Lines = append(detail.Lines, commands...)
detail.Lines = append(detail.Lines, unreadable...)
if len(commands) > 0 {
detail.Hint = "Run one of these commands to select the server to initialize."
}
return detail
}
func entryPointCommands(installations []discovery.Installation) []string {
var commands []string
seen := map[string]bool{}
for i := range installations {
for file := range installations[i].EntryPoints() {
if file.Path == "" || seen[file.Path] {
continue
}
seen[file.Path] = true
commands = append(commands, "lucy init --entry-point '"+strings.ReplaceAll(file.Path, "'", "'\\''")+"'")
}
}
return commands
}
func promptRuntime(
ctx context.Context,
cmd *cobra.Command,
inv *Invocation,
observed discovery.Result,
) (string, error) {
// A closed gate returns no selection, so Initialize reports the typed
// ambiguous-selection error whose commands name --entry-point exactly.
if !NewPromptGate(cmd, inv).Available() {
return "", nil
}
options := make([]huh.Option[string], 0, len(observed.Installations))
for i := range observed.Installations {
installation := &observed.Installations[i]
for file := range installation.EntryPoints() {
options = append(options, huh.NewOption(
fmt.Sprintf(
"%s %s / Minecraft %s (%s)",
installation.Identity.Product().Title(),
installation.Identity.Primary.Version,
installation.Identity.MinecraftVersion(),
file.Path,
),
file.Path,
))
}
}
if len(options) == 0 {
return "", nil
}
var selected string
form := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
Title("Select the server to initialize").
Description("Use --entry-point <entry-point> to make this selection without a prompt.").
Options(options...).Value(&selected))).
WithInput(cmd.InOrStdin()).WithOutput(cmd.ErrOrStderr())
if err := NormalizePromptError(ctx, form.RunWithContext(ctx)); err != nil {
return "", fmt.Errorf("server selection cancelled: %w", err)
}
return selected, nil
}
func flagStringInit(cmd *cobra.Command, name string) string {
value, _ := cmd.Flags().GetString(name)
return value
}
func flagBoolInit(cmd *cobra.Command, name string) bool {
value, _ := cmd.Flags().GetBool(name)
return value
}
package cli
import (
"fmt"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/javainstall"
"github.com/spf13/cobra"
)
const (
flagInstallLockedName = "locked"
flagInstallOfflineName = "offline"
flagInstallWithOptionalName = "with-optional"
flagInstallAcceptBrokenName = "accept-broken"
flagInstallOverwriteName = "overwrite"
flagInstallForceName = "force"
flagInstallJavaName = "java"
flagInstallJavaRepoName = "add-java-repository"
flagInstallMCDRVenvName = "mcdr-venv"
)
func NewInstallCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
cmd := &cobra.Command{
Use: "install",
Short: help.InstallShort,
Args: cobra.NoArgs,
Aliases: []string{"i"},
RunE: func(cmd *cobra.Command, args []string) error {
return actionInstall(cmd, inv, deps, args)
},
}
installJSON.Bind(contracts, cmd)
cmd.Flags().Bool(
flagInstallLockedName,
false,
"Do not resolve new versions; fail when the lock cannot satisfy",
)
cmd.Flags().Bool(
flagInstallOfflineName,
false,
"Disallow network acquisition; fail if any file is missing or mismatched",
)
cmd.Flags().Bool(
flagInstallWithOptionalName,
false,
"Also install optional dependencies and record them as explicit packages",
)
cmd.Flags().Bool(
flagInstallAcceptBrokenName,
false,
"Complete the installation even when required modules are missing or versions do not satisfy their constraints",
)
cmd.Flags().Bool(
flagInstallOverwriteName,
false,
"Replace files already present at package destinations",
)
cmd.Flags().BoolP(
flagInstallForceName,
"f",
false,
"Combine --accept-broken and --overwrite",
)
cmd.Flags().Bool(
flagInstallJavaName,
false,
"Also install the Java runtime the server asks for when no installed runtime meets it",
)
cmd.Flags().Bool(
flagInstallJavaRepoName,
false,
"Let --java add the Java vendor's package repository to the system package manager",
)
cmd.Flags().Bool(
flagInstallMCDRVenvName,
false,
"Create an isolated MCDReforged Python environment instead of using the global MCDReforged executable",
)
return cmd
}
func actionInstall(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) error {
locked, _ := cmd.Flags().GetBool(flagInstallLockedName)
offline, _ := cmd.Flags().GetBool(flagInstallOfflineName)
withOptional, _ := cmd.Flags().GetBool(flagInstallWithOptionalName)
acceptBroken, _ := cmd.Flags().GetBool(flagInstallAcceptBrokenName)
overwrite, _ := cmd.Flags().GetBool(flagInstallOverwriteName)
force, _ := cmd.Flags().GetBool(flagInstallForceName)
installJava, _ := cmd.Flags().GetBool(flagInstallJavaName)
addJavaRepository, _ := cmd.Flags().GetBool(flagInstallJavaRepoName)
mcdrVenv, _ := cmd.Flags().GetBool(flagInstallMCDRVenvName)
if addJavaRepository && !installJava {
return fmt.Errorf("--%s needs --%s", flagInstallJavaRepoName, flagInstallJavaName)
}
opts := install.Options{
Locked: locked,
Offline: offline,
AcceptBroken: acceptBroken || force,
Overwrite: overwrite || force,
WithOptional: withOptional,
MCDRVenv: mcdrVenv,
PublishManifest: false,
}
output := newInstallOutput(cmd, inv)
defer output.Close()
opts.Trace = output.Trace()
if installJava {
opts.ProvisionJava = output.provisionJava(
deps.JavaHost,
javainstall.Policy{
Interactive: NewPromptGate(cmd, inv).Available(),
AllowRepository: addJavaRepository,
},
offline,
)
}
ctx, stop := CommandContext(cmd)
defer stop()
result, err := deps.Operations.Sync(ctx, inv.Directory, opts)
if err != nil {
return output.Failure(err)
}
return output.Render(result)
}
package cli
import (
"context"
"fmt"
"io"
"github.com/mclucy/lucy/javainstall"
"github.com/mclucy/lucy/javaruntime"
"github.com/mclucy/lucy/report"
)
// provisionJava installs the first usable plan, most native first. A configured
// runtime is the user's choice, so the hook leaves it unchanged. Installation
// needs network access.
func (o *installOutput) provisionJava(
host *javainstall.Host,
policy javainstall.Policy,
offline bool,
) func(context.Context, javaruntime.Requirement, *javaruntime.Check) (string, error) {
return func(ctx context.Context, _ javaruntime.Requirement, check *javaruntime.Check) (string, error) {
if check.Source == javaruntime.SourceConfigured {
o.inv.Logger.Info(
"java installation skipped",
"reason", check.ConfigPath+" selects the Java runtime",
"home", check.ConfiguredHome,
)
return "", nil
}
if offline {
return "", &report.Detail{
Title: "cannot install Java while offline",
Lines: []string{fmt.Sprintf(
"no installed runtime meets the requirement for Java %d, and every installation method needs network access",
check.Target,
)},
Hint: "Run lucy install --java without --offline, or select a runtime with lucy config set java.home <home>.",
}
}
request := javainstall.Request{Major: check.Target, Distribution: check.Distribution}
plans, skipped, err := host.Plans(ctx, request, policy)
if err != nil {
return "", err
}
if len(plans) == 0 {
return "", noJavaPlanDetail(request, skipped)
}
plan := plans[0]
o.inv.Logger.Info(
"java install plan selected",
"method", plan.Method,
"package", plan.Package,
"alternatives", len(plans)-1,
"skipped", len(skipped),
)
var home string
err = o.suspend(func(w io.Writer) error {
fmt.Fprintf(
w,
"%s Java %d (%s) with %s\n",
o.inv.Theme.Accent("Installing"),
request.Major,
javaDistributionLabel(plan.Distribution),
plan.Method,
)
for _, line := range plan.Commands() {
fmt.Fprintln(w, " "+o.inv.Theme.Muted(line))
}
if plan.Method == javainstall.MethodDownload {
for _, s := range skipped {
fmt.Fprintln(w, " "+o.inv.Theme.Muted(skipText(s)))
}
}
var executeErr error
home, executeErr = host.Execute(ctx, plan, policy, o.input, w)
return executeErr
})
return home, err
}
}
// suspend stops the progress area while run owns the terminal, so a package
// manager can prompt for a password, then starts a new progress area.
func (o *installOutput) suspend(run func(io.Writer) error) error {
o.renderer.Close()
defer o.RestartProgress()
return run(o.Human())
}
func noJavaPlanDetail(request javainstall.Request, skipped []javainstall.Skipped) error {
detail := &report.Detail{
Title: fmt.Sprintf(
"no way to install Java %d (%s) is available here",
request.Major,
javaDistributionLabel(request.Distribution),
),
}
for _, s := range skipped {
detail.Lines = append(detail.Lines, skipText(s))
}
if len(skipped) == 0 {
detail.Hint = "Install the Java runtime manually, then run lucy config set java.home <home>."
}
return detail
}
func skipText(s javainstall.Skipped) string {
offer := fmt.Sprintf("%s offers %s", s.Plan.Method, s.Plan.Package)
switch s.Reason {
case javainstall.SkipNeedsRepositoryConsent:
return fmt.Sprintf(
"%s from %s (%s); rerun with --%s to add the repository",
offer,
s.Plan.Repository.Name,
s.Plan.Repository.URL,
flagInstallJavaRepoName,
)
case javainstall.SkipNeedsTerminal:
return offer + ", but it asks for confirmation; rerun in an interactive terminal"
case javainstall.SkipNeedsRoot:
return offer + ", but it needs root; rerun as root or with sudo access"
default:
return offer
}
}
package cli
import (
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/telemetry"
"github.com/mclucy/lucy/terminal/style"
)
// Invocation is the state of one `lucy` run that every command shares:
// where the run operates, which diagnostics it may write, and how it may
// present results. It carries no services and no ambient lookups; commands
// receive the services they use as explicit constructor parameters.
//
// Directory is resolved before actions run. Theme, Stdout, Stderr, and
// Interactive depend on parsed persistent flags, so the root wiring fills
// them before the action executes.
type Invocation struct {
// Directory is the absolute directory the command operates on. Server
// selection resolves it; handlers never read or change the process
// working directory.
Directory string
// Logger receives diagnostics for this run. A nil Logger deliberately
// records nothing.
Logger *log.Logger
// Theme styles human output.
Theme style.Theme
// Stdout describes the standard output terminal of this run.
Stdout style.Terminal
// Stderr describes the standard error terminal of this run.
Stderr style.Terminal
// Interactive reports whether prompts and animated output are allowed.
Interactive bool
// TelemetryEnabled permits projections of facts already observed by a command.
TelemetryEnabled bool
// TelemetryContexts holds only explicitly projected, bounded observations.
TelemetryContexts []telemetry.Context
}
// NewInvocation prepares the per-run state for directory. Presentation
// fields stay at their no-color, pipe defaults until the root wiring
// configures them.
func NewInvocation(directory string, logger *log.Logger) *Invocation {
return &Invocation{
Directory: directory,
Logger: logger,
Theme: style.New(false),
Stdout: style.Terminal{Width: 80, Height: 24},
Stderr: style.Terminal{Width: 80, Height: 24},
Interactive: false,
}
}
package cli
import (
"context"
"errors"
"fmt"
"io"
"os"
"slices"
"strconv"
"strings"
"charm.land/huh/v2"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/java"
"github.com/mclucy/lucy/javaruntime"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/operation"
"github.com/mclucy/lucy/report"
"github.com/mclucy/lucy/terminal/style"
"github.com/spf13/cobra"
)
const flagJavaDistributionName = "distribution"
// javaChoices lists the long-term support releases that the interactive form
// offers next to the installed major versions.
var javaChoices = []int{8, 11, 17, 21, 25}
var javaListJSON JSONResult[javaListView]
var javaUseJSON JSONResult[*operation.JavaChangeResult]
func NewJavaCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
javaCmd := &cobra.Command{
Use: "java <subcommand>",
Short: help.JavaShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
MarkHelpOnly(javaCmd)
javaListCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: help.JavaListShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionJavaList(cmd, inv, deps, args)
},
}
javaUseCmd := &cobra.Command{
Use: "use [version|auto]",
Short: help.JavaUseShort,
Long: help.JavaUseLong,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return actionJavaUse(cmd, inv, deps, args)
},
}
javaListJSON.Bind(contracts, javaListCmd)
javaUseJSON.Bind(contracts, javaUseCmd)
javaUseCmd.Flags().String(
flagJavaDistributionName,
"",
"Pin a Java distribution in lucy.yaml, or auto to accept any",
)
javaCmd.AddCommand(javaListCmd, javaUseCmd)
return javaCmd
}
// javaListView is the machine-readable `java list` result.
type javaListView struct {
Requirement *javaruntime.Requirement `json:"requirement,omitempty"`
Selection *javaruntime.Check `json:"selection,omitempty"`
Runtimes []javaRuntimeView `json:"runtimes"`
}
type javaRuntimeView struct {
javaruntime.Runtime
Selected bool `json:"selected"`
Problems []string `json:"problems,omitempty"`
}
func actionJavaList(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) error {
root := inv.Directory
ctx, stop := CommandContext(cmd)
defer stop()
requirement, err := lockedJavaRequirement(root, inv)
if err != nil {
return err
}
runtimes, err := javaruntime.Installed(ctx, inv.Logger)
if err != nil {
recordJavaTelemetryFailure(inv)
return err
}
view := javaListView{Requirement: requirement, Runtimes: []javaRuntimeView{}}
if requirement != nil {
if view.Selection, err = javaruntime.Select(
ctx,
root,
*requirement,
inv.Logger,
); err != nil {
recordJavaTelemetryFailure(inv)
return err
}
}
for _, runtime := range runtimes {
entry := javaRuntimeView{Runtime: runtime}
if requirement != nil {
entry.Problems = requirement.Misses(&runtime)
}
entry.Selected = view.Selection != nil && view.Selection.Runtime != nil &&
view.Selection.Runtime.Home == runtime.Home
view.Runtimes = append(view.Runtimes, entry)
}
recordJavaTelemetry(inv, view.Selection)
output := NewQueryOutput(cmd, inv)
if output.JSON() {
return javaListJSON.Write(output, view)
}
out := output.Human()
if requirement == nil {
fmt.Fprintf(
out,
"No %s; run lucy install to resolve the Java the server asks for.\n",
document.LockFilename,
)
} else {
writeJavaRequirement(out, requirement, inv.Theme)
}
if view.Selection != nil && view.Selection.Source == javaruntime.SourceConfigured {
fmt.Fprintf(out, "%s selects %s\n", view.Selection.ConfigPath, view.Selection.ConfiguredHome)
}
fmt.Fprintln(out)
if len(view.Runtimes) == 0 {
fmt.Fprintln(out, "No Java runtime found.")
}
for _, entry := range view.Runtimes {
writeJavaRuntime(out, entry, requirement != nil, inv.Theme)
}
if view.Selection != nil {
reporter := report.New(cmd.ErrOrStderr(), inv.Theme)
for _, warning := range javaWarnings(view.Selection) {
reporter.Warn(warning)
}
}
return nil
}
func writeJavaRequirement(
out io.Writer,
requirement *javaruntime.Requirement,
theme style.Theme,
) {
target := strconv.Itoa(requirement.Target())
fmt.Fprintf(
out,
"%s %s\n",
theme.Accent("Requires"),
javaSpecLabel(target, requirement.Distribution),
)
width := 0
for _, c := range requirement.Constraints {
width = max(width, len(c.Source))
}
for _, c := range requirement.Constraints {
fmt.Fprintf(
out,
" %-*s %s\n",
width,
c.Source,
theme.Muted(c.Expression),
)
}
}
func writeJavaRuntime(
out io.Writer,
entry javaRuntimeView,
checked bool,
theme style.Theme,
) {
marker := " "
switch {
case !checked:
case len(entry.Problems) == 0:
marker = theme.Success("✓")
default:
marker = theme.Failure("✗")
}
line := fmt.Sprintf(
"%s %-3d %-12s %-22s %s",
marker,
entry.Major,
entry.Version,
entry.Build(),
theme.Muted(entry.Home),
)
if entry.Selected {
line += " " + theme.Accent("(selected)")
}
fmt.Fprintln(out, line)
}
func actionJavaUse(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
args []string,
) error {
root := inv.Directory
ctx, stop := CommandContext(cmd)
defer stop()
change, err := javaChangeFromFlags(cmd, args)
if err != nil {
return err
}
if change == (operation.JavaChange{}) {
if !NewPromptGate(cmd, inv).Available() {
return fmt.Errorf(
"specify a Java version, auto, or --%s; interactive selection is unavailable "+
"with --no-interactive or without a terminal",
flagJavaDistributionName,
)
}
if change, err = promptJavaChange(ctx, root, NewPromptGate(cmd, inv), inv.Logger); err != nil {
return err
}
}
result, err := deps.Operations.SetJava(ctx, root, change)
if err != nil {
return err
}
output := NewOperationOutput(cmd, inv)
if output.JSON() {
return javaUseJSON.Write(output, result)
}
out := output.Human()
fmt.Fprintf(out, "%s: %s\n", document.ManifestFilename, javaOverrideLabel(result.Java))
if result.PublishedManifest {
fmt.Fprintf(out, "Run lucy install to update %s.\n", document.LockFilename)
}
return nil
}
func javaChangeFromFlags(cmd *cobra.Command, args []string) (operation.JavaChange, error) {
var change operation.JavaChange
if len(args) == 1 {
version, err := parseJavaMajor(args[0])
if err != nil {
return change, err
}
change.Version = &version
}
if cmd.Flags().Changed(flagJavaDistributionName) {
raw, _ := cmd.Flags().GetString(flagJavaDistributionName)
distribution, err := document.ParseJavaDistribution(raw)
if err != nil {
return change, err
}
change.Distribution = &distribution
}
return change, nil
}
func parseJavaMajor(raw string) (int, error) {
raw = strings.TrimSpace(raw)
if strings.EqualFold(raw, document.JavaAuto) {
return 0, nil
}
version, err := strconv.Atoi(raw)
if err != nil || version < document.MinimumJava {
return 0, fmt.Errorf(
"%q is not a Java major version of %d or later, or auto",
raw,
document.MinimumJava,
)
}
return version, nil
}
// promptJavaChange asks for the Java version and distribution that lucy.yaml
// pins. Each answer starts at the current value.
func promptJavaChange(
ctx context.Context,
root string,
gate PromptGate,
logger *log.Logger,
) (operation.JavaChange, error) {
d, err := document.ManifestFile(root).Read()
if err != nil {
return operation.JavaChange{}, err
}
runtimes, err := javaruntime.Installed(ctx, logger)
if err != nil {
return operation.JavaChange{}, err
}
current := document.ManifestJava{}
if d.Server.Java != nil {
current = *d.Server.Java
}
version, distribution := current.Version, current.Distribution
majors := slices.Clone(javaChoices)
for _, runtime := range runtimes {
majors = append(majors, runtime.Major)
}
if version != 0 {
majors = append(majors, version)
}
slices.Sort(majors)
majors = slices.Compact(majors)
slices.Reverse(majors)
versionOptions := []huh.Option[int]{
huh.NewOption("auto (the version Mojang recommends)", 0),
}
for _, major := range majors {
versionOptions = append(versionOptions, huh.NewOption("Java "+strconv.Itoa(major), major))
}
distributionOptions := []huh.Option[java.Distribution]{
huh.NewOption("auto (any distribution)", java.DistributionAny),
}
for _, known := range java.Distributions {
distributionOptions = append(
distributionOptions,
huh.NewOption(known.Title()+" ("+string(known)+")", known),
)
}
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title("Java version in "+document.ManifestFilename).
Options(versionOptions...).
Value(&version),
huh.NewSelect[java.Distribution]().
Title("Java distribution in "+document.ManifestFilename).
Options(distributionOptions...).
Value(&distribution),
),
).
WithInput(gate.Input()).
WithOutput(gate.Output())
if err := NormalizePromptError(ctx, form.RunWithContext(ctx)); err != nil {
return operation.JavaChange{}, err
}
return operation.JavaChange{
Version: &version,
Distribution: &distribution,
}, nil
}
// lockedJavaRequirement returns the Java requirement of the lock in root, or
// nil when the workspace has no lock.
func lockedJavaRequirement(root string, inv *Invocation) (*javaruntime.Requirement, error) {
lock, err := document.LockFile(root).Read()
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
requirement := javaruntime.RequirementOf(lock, inv.Logger)
return &requirement, nil
}
// javaWarnings turns the problems of a Java check into warnings, with a hint
// that names the command that fixes them.
func javaWarnings(check *javaruntime.Check) []error {
if check == nil || check.Satisfied() {
return nil
}
warnings := make([]error, 0, len(check.Problems)+1)
for _, problem := range check.Problems {
warnings = append(warnings, errors.New(problem))
}
if check.Source == javaruntime.SourceConfigured {
warnings = append(warnings, errors.New(
"run lucy config set java.home <home> to select another Java runtime, "+
"or lucy config set java.home '' to let Lucy choose",
))
return warnings
}
warnings = append(warnings, fmt.Errorf(
"run lucy install --java to install Java %d (%s)",
check.Target,
javaDistributionLabel(check.Distribution),
))
return warnings
}
func javaDistributionLabel(distribution java.Distribution) string {
if distribution == java.DistributionAny {
return "any distribution"
}
return distribution.Title()
}
func javaSpecLabel(version string, distribution java.Distribution) string {
return "Java " + version + " from " + javaDistributionLabel(distribution)
}
func javaOverrideLabel(override *document.ManifestJava) string {
if override.IsAuto() {
return javaSpecLabel(document.JavaAuto, java.DistributionAny)
}
version := document.JavaAuto
if override.Version != 0 {
version = strconv.Itoa(override.Version)
}
return javaSpecLabel(version, override.Distribution)
}
package cli
import (
"fmt"
"reflect"
"slices"
"strconv"
"strings"
"github.com/invopop/jsonschema"
"github.com/spf13/cobra"
)
// JSONResult binds the type written by a command to its discoverable contract.
type JSONResult[T any] struct{}
func (JSONResult[T]) Bind(contracts *JSONContracts, cmd *cobra.Command) {
t := reflect.TypeFor[T]()
if !slices.Contains(contracts.results[cmd], t) {
contracts.results[cmd] = append(contracts.results[cmd], t)
}
}
func (JSONResult[T]) Write(output *Output, value T) error {
return output.writeJSON(output.cmd.OutOrStdout(), value)
}
// JSONContracts belongs to one command tree. Schemas are generated only on lookup.
type JSONContracts struct {
results map[*cobra.Command][]reflect.Type
ignored map[*cobra.Command]bool
native map[*cobra.Command]bool
}
func NewJSONContracts() *JSONContracts {
return &JSONContracts{
results: make(map[*cobra.Command][]reflect.Type),
ignored: make(map[*cobra.Command]bool),
native: make(map[*cobra.Command]bool),
}
}
func (c *JSONContracts) Ignore(cmd *cobra.Command) { c.ignored[cmd] = true }
func (c *JSONContracts) Native(cmd *cobra.Command) { c.native[cmd] = true }
func (c *JSONContracts) Ignores(cmd *cobra.Command) bool {
for current := cmd; current != nil; current = current.Parent() {
if c.ignored[current] {
return true
}
}
return false
}
func (c *JSONContracts) IsNative(cmd *cobra.Command) bool { return c.native[cmd] }
func (c *JSONContracts) Supports(cmd *cobra.Command) bool {
return len(c.results[cmd]) > 0 || c.IsNative(cmd)
}
func (c *JSONContracts) Paths(root *cobra.Command) []string {
paths := make([]string, 0)
prefix := root.Root().Name() + " "
for cmd := range c.results {
for ancestor := cmd; ancestor != nil; ancestor = ancestor.Parent() {
if ancestor == root {
paths = append(paths, strings.TrimPrefix(cmd.CommandPath(), prefix))
break
}
}
}
slices.Sort(paths)
return paths
}
func (c *JSONContracts) Schema(cmd *cobra.Command) (*jsonschema.Schema, error) {
types := c.results[cmd]
if len(types) == 0 {
return nil, fmt.Errorf("%s does not provide a JSON result schema", cmd.CommandPath())
}
schema := reflectJSONSchema(types)
schema.Title = cmd.CommandPath() + " JSON result"
return schema, nil
}
func reflectJSONSchema(types []reflect.Type) *jsonschema.Schema {
reflector := &jsonschema.Reflector{
Anonymous: true,
AdditionalFields: jsonOutputFields,
Namer: func(t reflect.Type) string {
if t.Name() == "" {
return ""
}
return strings.ReplaceAll(t.PkgPath(), "/", ".") + "." + t.Name()
},
}
if len(types) == 1 {
return reflector.ReflectFromType(types[0])
}
result := &jsonschema.Schema{
Version: jsonschema.Version,
Definitions: make(jsonschema.Definitions),
AnyOf: make([]*jsonschema.Schema, 0, len(types)),
}
for _, t := range types {
variant := reflector.ReflectFromType(t)
for name, definition := range variant.Definitions {
result.Definitions[name] = definition
}
variant.Version = ""
variant.ID = ""
variant.Definitions = nil
result.AnyOf = append(result.AnyOf, variant)
}
return result
}
// The reflector's defaults do not account for nullable pointers or JSON-v2's
// presence rules for numeric and boolean fields tagged with omitempty.
func jsonOutputFields(t reflect.Type) []reflect.StructField {
fields := make([]reflect.StructField, 0)
for i := range t.NumField() {
field := t.Field(i)
if !field.IsExported() {
continue
}
tags := strings.Split(field.Tag.Get("json"), ",")
if tags[0] == "-" || field.Anonymous && tags[0] == "" {
continue
}
omitEmpty := slices.Contains(tags[1:], "omitempty")
omitZero := slices.Contains(tags[1:], "omitzero")
keyword := ""
switch field.Type.Kind() {
case reflect.Pointer:
if !omitEmpty && !omitZero {
keyword = "nullable"
}
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
if omitEmpty && !omitZero {
keyword = "required"
}
}
if keyword == "" {
continue
}
old, exists := field.Tag.Lookup("jsonschema")
updated := keyword
if old != "" {
updated = old + "," + keyword
}
tag := string(field.Tag)
if exists {
tag = strings.Replace(tag, "jsonschema:"+strconv.Quote(old), "jsonschema:"+strconv.Quote(updated), 1)
} else {
tag += " jsonschema:" + strconv.Quote(updated)
}
field.Tag = reflect.StructTag(tag)
fields = append(fields, field)
}
return fields
}
package cli
import (
"fmt"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/spf13/cobra"
)
var leavesJSON JSONResult[leavesOutput]
func NewLeavesCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
leavesCmd := &cobra.Command{
Use: "leaves",
Short: help.LeavesShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionLeaves(cmd, inv, deps, args)
},
}
leavesJSON.Bind(contracts, leavesCmd)
leavesCmd.Flags().Bool(
"live",
false,
"Observe the live workspace instead of reading the lock",
)
return leavesCmd
}
func actionLeaves(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) error {
ctx, stop := CommandContext(cmd)
defer stop()
forceLive, _ := cmd.Flags().GetBool("live")
output := NewQueryOutput(cmd, inv)
graph, origin, _, err := LoadDependencyData(
ctx,
inv.Directory,
forceLive,
deps.Router.ResolveArtifactByHash,
output,
)
if err != nil {
return err
}
if output.JSON() {
return leavesJSON.Write(output, leavesDocument(graph, origin, output.Warnings()))
}
human := output.Human()
fmt.Fprintf(human, "Using data from: %s\n\n", origin.String())
leaves := graph.GetLeaves()
if len(leaves) == 0 {
fmt.Fprintln(human, "No leaf packages found")
} else {
for _, leaf := range leaves {
fmt.Fprintln(human, nodeLabel(leaf))
}
}
fmt.Fprintf(human, "\n(from %s)\n", origin.String())
return nil
}
type leafNode struct {
ID string `json:"id"`
Version string `json:"version"`
Provider string `json:"provider,omitempty"`
Project string `json:"project,omitempty"`
Filename string `json:"filename,omitempty"`
Environment string `json:"environment"`
Ecosystem string `json:"ecosystem"`
}
// leavesOutput is the complete machine-readable `leaves` result.
type leavesOutput struct {
Leaves []leafNode `json:"leaves"`
Origin string `json:"origin"`
Warnings []string `json:"warnings,omitempty"`
}
func leavesDocument(
graph *DependencyGraph,
origin GraphOrigin,
warnings []string,
) leavesOutput {
leaves := graph.GetLeaves()
jsonLeaves := make([]leafNode, 0, len(leaves))
for _, leaf := range leaves {
jsonLeaves = append(
jsonLeaves, leafNode{
ID: leaf.ID,
Version: leaf.Version,
Provider: leaf.Provider.String(),
Project: leaf.ProjectID,
Filename: leaf.Filename,
Environment: string(leaf.Environment),
Ecosystem: leaf.Ecosystem.String(),
},
)
}
return leavesOutput{
Leaves: jsonLeaves,
Origin: origin.String(),
Warnings: warnings,
}
}
package cli
import (
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/mclucy/lucy/bootstrap"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/report"
"github.com/mclucy/lucy/resolve"
"github.com/mclucy/lucy/terminal/style"
"github.com/spf13/cobra"
)
// installJSON is the machine-readable result shared by every operation that
// resolves, publishes, and installs: add, remove, install, init, and upgrade.
var installJSON JSONResult[*install.Result]
// installOutput adapts operation traces and results to the command presenter.
type installOutput struct {
*Output
input io.Reader
phase string
resolved int
pending int
rejected int
rejectedProject string
fetchedBase int64
fetchedCurrent int64
}
func newInstallOutput(cmd *cobra.Command, inv *Invocation) *installOutput {
output := NewOperationOutput(cmd, inv)
output.Progress()
return &installOutput{Output: output, input: cmd.InOrStdin()}
}
// Trace returns the hooks the operation reports through.
func (o *installOutput) Trace() *install.Trace {
return &install.Trace{
PhaseStarted: o.phaseStarted,
PhaseDone: o.phaseDone,
FetchStarted: o.fetchStarted,
FetchProgress: o.fetchProgress,
FetchDone: o.fetchDone,
ProcessOutput: processLog{o},
Resolve: resolve.Trace{
State: o.resolveState,
Rejected: o.resolveRejected,
},
}
}
func (o *installOutput) phaseStarted(name string) {
o.phase = name
o.resolved = 0
o.pending = 0
o.rejected = 0
o.rejectedProject = ""
o.renderer.PhaseStarted(phaseLabel(name))
}
func (o *installOutput) phaseDone(phase install.PhaseInfo) {
o.phase = ""
text := phaseText(phase)
if text == "" {
return
}
o.renderer.PhaseDone(text, phase.Elapsed)
}
func (o *installOutput) fetchStarted(name string, size int64) {
o.fetchedCurrent = 0
o.renderer.Download(name, 0, size)
o.updateStatus()
}
func (o *installOutput) fetchProgress(name string, read, total int64) {
o.fetchedCurrent = read
o.renderer.Download(name, read, total)
o.updateStatus()
}
func (o *installOutput) fetchDone(string, bool) {
o.fetchedBase += o.fetchedCurrent
o.fetchedCurrent = 0
o.renderer.DownloadDone()
o.updateStatus()
}
func (o *installOutput) resolveState(resolved, pending int) {
o.resolved = resolved
o.pending = pending
o.updateStatus()
}
func (o *installOutput) resolveRejected(project, _, _ string) {
o.rejected++
o.rejectedProject = project
o.updateStatus()
}
func (o *installOutput) updateStatus() {
if o.phase == "" {
return
}
o.renderer.Status(o.statusText())
}
func (o *installOutput) statusText() string {
var parts []string
if o.phase == "packages" {
parts = append(
parts,
fmt.Sprintf("%d resolved", o.resolved),
fmt.Sprintf("%d pending", o.pending),
)
}
if fetched := o.fetchedBase + o.fetchedCurrent; fetched > 0 {
parts = append(parts, style.FormatBytesDecimal(fetched)+" fetched")
}
if o.rejectedProject != "" {
parts = append(parts, fmt.Sprintf("%d builds of %s rejected", o.rejected, o.rejectedProject))
}
return strings.Join(parts, ", ")
}
// Render prints the result. Human output goes to stderr; JSON goes to stdout.
func (o *installOutput) Render(result *install.Result) error {
if result == nil {
return nil
}
result.Warnings = append(result.Warnings, javaWarnings(result.Java)...)
result.WarningMessages = append(warningMessages(result.Warnings), o.Warnings()...)
if o.JSON() {
return installJSON.Write(o.Output, result)
}
if !result.Changed() {
if !result.PublishedManifest && !result.PublishedLock {
line := o.inv.Theme.Success("✓") + " Already up to date"
if result.TotalPackages > 0 {
line = o.inv.Theme.Success("✓") + " " +
packageCountLabel(result.TotalPackages) + " already up to date"
}
o.renderer.Println(line)
}
} else {
var changes []string
if result.Server.Change == install.ChangeUpgraded {
changes = append(changes, serverChangeLine(result.Server, o.inv.Theme))
}
changes = append(changes, packageChangeLines(result.Packages, o.inv.Theme)...)
if len(changes) > 0 {
o.renderer.Println(append([]string{""}, changes...)...)
}
}
if trailer := publicationLine(result, o.inv.Theme); trailer != "" {
o.renderer.Println("", trailer)
}
// Flush the summary before warnings so the two surfaces cannot interleave.
o.renderer.Close()
for _, warning := range result.Warnings {
o.Warn(warning)
}
for _, diagnostic := range result.Diagnostics {
o.Warn(fmt.Errorf("%s: %s", diagnostic.Path, diagnostic.Message))
}
return nil
}
// Failure converts an operation error into the detail the command framework
// renders. Unknown errors pass through unchanged.
func (o *installOutput) Failure(err error) error {
if err == nil {
return nil
}
var failure *resolve.Failure
if errors.As(err, &failure) {
return resolutionDetail(failure)
}
var committed *install.CommittedError
if errors.As(err, &committed) &&
(errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) {
return &report.Detail{
Title: "interrupted",
Lines: []string{
"The installation was committed, but file provisioning was interrupted.",
"Run lucy install again to finish.",
},
Cause: err,
}
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return &report.Detail{
Title: "interrupted",
Lines: []string{"The installation stopped before it committed; staged files were rolled back."},
Cause: err,
}
}
if errors.Is(err, bootstrap.ErrNoJava) {
return &report.Detail{
Title: "no Java runtime",
Lines: []string{err.Error()},
Hint: "Run lucy install --java to install the Java runtime the server asks for.",
Cause: err,
}
}
return err
}
// processLog forwards subprocess output to the current progress area, which
// changes when a Java installation suspends it.
type processLog struct{ o *installOutput }
func (l processLog) Write(data []byte) (int, error) {
return l.o.renderer.LogWriter().Write(data)
}
func warningMessages(warnings []error) []string {
messages := make([]string, 0, len(warnings))
for _, warning := range warnings {
if warning != nil {
messages = append(messages, warning.Error())
}
}
return messages
}
func phaseLabel(name string) string {
switch name {
case "server":
return "Resolving server"
case "packages":
return "Resolving packages"
case "python environment":
return "Preparing Python environment"
case "install":
return "Installing"
default:
return style.Capitalize(name)
}
}
func phaseText(phase install.PhaseInfo) string {
switch phase.Name {
case "server":
if phase.Version == "" {
return fmt.Sprintf("Resolved %s", phase.Product)
}
return fmt.Sprintf(
"Resolved %s %s for Minecraft %s",
phase.Product,
phase.Version,
phase.Minecraft,
)
case "packages":
if phase.Count == 0 && phase.Total == 0 {
return ""
}
if phase.Total > 0 && phase.Count > phase.Total {
return fmt.Sprintf(
"Resolved %d packages (%d requested, %d dependencies)",
phase.Count,
phase.Total,
phase.Count-phase.Total,
)
}
return fmt.Sprintf("Resolved %d packages", phase.Count)
case "python environment":
return "Prepared Python environment"
case "install":
if phase.Count == 0 {
return ""
}
if phase.Count == 1 {
return "Installed 1 file"
}
return fmt.Sprintf("Installed %d files", phase.Count)
default:
return style.Capitalize(phase.Name)
}
}
func serverChangeLine(change install.ServerChange, theme style.Theme) string {
version := string(change.Version)
if change.Change == install.ChangeUpgraded {
version = fmt.Sprintf("%s → %s", change.Previous, change.Version)
}
return fmt.Sprintf(
" %s %s %s",
changeGlyph(change.Change, theme),
change.Product,
version,
)
}
func packageChangeLines(changes []install.PackageChange, theme style.Theme) []string {
width := 0
for _, change := range changes {
width = max(width, len(change.Name))
}
lines := make([]string, 0, len(changes))
for _, change := range changes {
version := string(change.Version)
switch change.Change {
case install.ChangeUpgraded, install.ChangeDowngraded, install.ChangeChanged:
version = fmt.Sprintf("%s → %s", change.Previous, change.Version)
case install.ChangeRebuilt:
if change.PreviousReleaseID != "" && change.ReleaseID != "" && change.PreviousReleaseID != change.ReleaseID {
version = fmt.Sprintf("%s (build %s → %s)", change.Version, change.PreviousReleaseID, change.ReleaseID)
} else {
version = fmt.Sprintf("%s (build changed)", change.Version)
}
}
line := fmt.Sprintf(
" %s %-*s %s",
changeGlyph(change.Change, theme),
width,
change.Name,
version,
)
if change.Change == install.ChangeAdded && !change.Direct && len(change.RequiredBy) > 0 {
line += " " + theme.Muted("dependency of "+joinLimited(change.RequiredBy, 2))
}
lines = append(lines, line)
}
return lines
}
func changeGlyph(change install.Change, theme style.Theme) string {
switch change {
case install.ChangeAdded:
return theme.Success("+")
case install.ChangeRemoved:
return theme.Failure("-")
case install.ChangeUpgraded:
return theme.Warning("~")
case install.ChangeDowngraded:
return theme.Failure("v")
case install.ChangeRebuilt:
return theme.Warning("r")
case install.ChangeChanged:
return theme.Warning("!")
default:
return " "
}
}
func joinLimited(values []string, limit int) string {
if len(values) <= limit {
return strings.Join(values, ", ")
}
return fmt.Sprintf("%s and %d more", strings.Join(values[:limit], ", "), len(values)-limit)
}
func pluralBuilds(count int) string {
if count == 1 {
return "1 build"
}
return fmt.Sprintf("%d builds", count)
}
func packageCountLabel(count int) string {
if count == 1 {
return "1 package"
}
return fmt.Sprintf("%d packages", count)
}
func publicationLine(result *install.Result, theme style.Theme) string {
switch {
case result.PublishedManifest && result.PublishedLock:
return theme.Muted("Updated lucy.yaml and lucy-lock.yaml")
case result.PublishedManifest:
return theme.Muted("Updated lucy.yaml")
case result.PublishedLock:
return theme.Muted("Updated lucy-lock.yaml")
default:
return ""
}
}
func resolutionDetail(failure *resolve.Failure) *report.Detail {
detail := &report.Detail{Cause: failure}
if failure.Aborted {
detail.Title = failure.Error()
if failure.Project != "" {
detail.Lines = append(detail.Lines, fmt.Sprintf(
"%s rejected %d builds for Minecraft %s on %s before the search stopped",
failure.Project,
failure.Candidates,
failure.Minecraft,
failure.Ecosystem,
))
appendRejectionGroups(detail, failure)
}
return detail
}
versionOnly := len(failure.Groups) == 1 &&
failure.Groups[0].Kind == resolve.RejectionVersionMismatch
if versionOnly {
group := failure.Groups[0]
detail.Title = fmt.Sprintf("%s has no build matching %q", failure.Project, group.Detail)
if failure.Candidates > 0 {
detail.Lines = append(detail.Lines, fmt.Sprintf(
"%d builds exist for Minecraft %s on %s",
failure.Candidates,
failure.Minecraft,
failure.Ecosystem,
))
}
if group.FirstVersion != "" && group.LastVersion != "" && group.FirstVersion != group.LastVersion {
detail.Lines = append(detail.Lines, group.FirstVersion+" … "+group.LastVersion)
}
if group.FirstVersion != "" {
detail.Hint = fmt.Sprintf("lucy add %s@%s", failure.Project, group.FirstVersion)
}
return detail
}
if constraint, ok := serverConstraint(failure.Groups); ok {
detail.Title = fmt.Sprintf(
"%s requires %s %s, but this server runs %s",
constraint.RequiredBy,
constraint.Dependency,
constraint.Constraint,
constraint.Selected,
)
detail.Lines = append(detail.Lines, fmt.Sprintf(
"no build of %s for Minecraft %s on %s can change it",
failure.Project,
failure.Minecraft,
failure.Ecosystem,
))
appendRejectionGroups(detail, failure)
return detail
}
detail.Title = fmt.Sprintf("no build of %s works on this server", failure.Project)
if failure.Candidates > 0 {
detail.Lines = append(detail.Lines, fmt.Sprintf(
"checked %d builds for Minecraft %s on %s",
failure.Candidates,
failure.Minecraft,
failure.Ecosystem,
))
}
appendRejectionGroups(detail, failure)
return detail
}
// serverConstraint returns the requirement the server itself fails to satisfy.
// No candidate can change it, so it is the cause of the failure.
func serverConstraint(groups []resolve.RejectionGroup) (resolve.RejectionGroup, bool) {
for _, group := range groups {
if group.Kind == resolve.RejectionServer {
return group, true
}
}
return resolve.RejectionGroup{}, false
}
// appendRejectionGroups reports each aggregated rejection on its own line.
func appendRejectionGroups(detail *report.Detail, failure *resolve.Failure) {
for _, group := range failure.Groups {
switch group.Kind {
case resolve.RejectionEmpty:
detail.Lines = append(detail.Lines, group.Detail)
case resolve.RejectionModules, resolve.RejectionServer:
prefix := pluralBuilds(group.Count)
if group.Count == failure.Candidates {
prefix = fmt.Sprintf("all %d", group.Count)
}
detail.Lines = append(detail.Lines, prefix+": "+group.Detail)
default:
detail.Lines = append(detail.Lines, pluralBuilds(group.Count)+": "+group.Detail)
}
}
}
package cli
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"github.com/charmbracelet/x/ansi"
"github.com/mclucy/lucy/report"
"github.com/mclucy/lucy/resolve"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const (
// ErrorSchema versions the machine-readable failure document.
ErrorSchema = 1
// ErrorKind identifies a machine-readable failure document.
ErrorKind = "error"
// CodeCommandFailed marks an action that failed for an unclassified
// reason.
CodeCommandFailed = "command.failed"
// CodeInvalidInvocation marks a command line that could not be parsed or
// validated before the action ran.
CodeInvalidInvocation = "usage.invalid_invocation"
// CodeOperationCancelled marks an action interrupted through its context.
CodeOperationCancelled = "operation.cancelled"
// CodeResolveNoMatchingBuild marks a version request with no matching
// builds.
CodeResolveNoMatchingBuild = "resolve.no_matching_build"
// CodeResolveNoCompatibleBuild marks a project whose builds do not fit
// this server.
CodeResolveNoCompatibleBuild = "resolve.no_compatible_build"
)
// ErrorDocument is the single document a failure writes to stderr in machine
// mode.
type ErrorDocument struct {
Schema int `json:"schema"`
Kind string `json:"kind"`
Code string `json:"code"`
Title string `json:"title"`
Details []string `json:"details"`
Hint string `json:"hint,omitempty"`
// Facts carries the machine-readable evidence of the error tree. Human
// rendering ignores it; the JSON document and the telemetry report body
// read it, so both describe a failure with the same data.
Facts map[string]any `json:"facts,omitempty"`
Log string `json:"log"`
}
// SetJSONRendering overrides the rendering selected when the output was
// created. The command boundary uses it when flags fail before parsing.
func (o *Output) SetJSONRendering(mode JSONRendering) {
o.json = mode
}
// JSONModeFromArgs finds output flags without mistaking another flag's value
// for an output option. It also works when Cobra stops parsing on an error.
func JSONModeFromArgs(cmd *cobra.Command, args []string) JSONRendering {
pretty, compact := false, false
flagSets := [3]*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags(), cmd.PersistentFlags()}
findFlag := func(name string, shorthand bool) *pflag.Flag {
for _, flags := range flagSets {
if shorthand {
if flag := flags.ShorthandLookup(name); flag != nil {
return flag
}
} else if flag := flags.Lookup(name); flag != nil {
return flag
}
}
return nil
}
for index := 0; index < len(args); index++ {
arg := args[index]
if arg == "--" {
break
}
if name, long := strings.CutPrefix(arg, "--"); long {
name, value, assigned := strings.Cut(name, "=")
flag := findFlag(name, false)
if flag != nil && flag.NoOptDefVal == "" && !assigned {
index++
continue
}
if !assigned {
value = "true"
}
switch name {
case FlagJSON:
pretty = boolValue(value)
case FlagJSONCompact:
compact = boolValue(value)
}
continue
}
if shorthand, short := strings.CutPrefix(arg, "-"); short {
for len(shorthand) > 0 {
flag := findFlag(shorthand[:1], true)
if flag == nil {
break
}
shorthand = shorthand[1:]
if flag.NoOptDefVal == "" {
if shorthand == "" {
index++
}
break
}
if strings.HasPrefix(shorthand, "=") {
break
}
}
}
}
if compact {
return JSONCompact
}
if pretty {
return JSONPretty
}
return JSONNone
}
func boolValue(value string) bool {
parsed, err := strconv.ParseBool(value)
return err == nil && parsed
}
// IsUsageError reports failures that happen before a command's own action:
// unknown commands or flags, missing flag values, and rejected arguments.
func IsUsageError(err error) bool {
if err == nil {
return false
}
if isUsageMessage(err.Error()) {
return true
}
switch unwrapped := err.(type) {
case interface{ Unwrap() []error }:
for _, child := range unwrapped.Unwrap() {
if IsUsageError(child) {
return true
}
}
case interface{ Unwrap() error }:
return IsUsageError(unwrapped.Unwrap())
}
return false
}
func isUsageMessage(message string) bool {
for _, prefix := range []string{
"flag needs an argument:",
"unknown flag:",
"unknown shorthand flag:",
"unknown command",
"invalid argument",
"accepts ",
"requires at least ",
"required flag(s)",
} {
if strings.HasPrefix(message, prefix) {
return true
}
}
return false
}
// IsFrameworkUsageError reports the subset of invalid invocations that the
// command framework renders with its own usage hint.
func IsFrameworkUsageError(err error) bool {
if err == nil {
return false
}
message := err.Error()
for _, prefix := range []string{
"flag needs an argument:",
"unknown flag:",
"unknown shorthand flag:",
"unknown command",
"invalid argument",
} {
if strings.HasPrefix(message, prefix) {
return true
}
}
return false
}
// UsageHint names the flags this command accepts when the framework rejected
// an unknown flag or shorthand before the command ran. Mistaking a sibling
// command's flag for this one is the common failure, and the flag list answers
// it without a second round trip to --help. It returns an empty string for
// every other failure.
func UsageHint(cmd *cobra.Command, err error) string {
if _, ok := unknownFlagName(err); !ok {
return ""
}
names := localFlagNames(cmd)
if len(names) == 0 {
return ""
}
return cmd.CommandPath() + " accepts " + strings.Join(names, ", ") + "."
}
// unknownFlagName reports the rejected flag, spelled as the caller wrote it.
func unknownFlagName(err error) (string, bool) {
if err == nil {
return "", false
}
message := err.Error()
if name, ok := strings.CutPrefix(message, "unknown flag: "); ok {
return name, true
}
rest, ok := strings.CutPrefix(message, "unknown shorthand flag: ")
if !ok || len(rest) < 3 || rest[0] != '\'' {
return "", false
}
end := strings.Index(rest[1:], "'")
if end <= 0 {
return "", false
}
return "-" + rest[1:1+end], true
}
// localFlagNames lists the flags this command answers to, sorted for a stable
// message: everything it declares itself, plus the persistent flags of the
// command groups above it, such as `lucy config --global`. The root's
// persistent set stays out because it is identical for every command and does
// not explain this rejection, and so do the framework's --help and --version.
func localFlagNames(cmd *cobra.Command) []string {
if cmd == nil {
return nil
}
collected := map[string]bool{}
collect := func(flags *pflag.FlagSet) {
flags.VisitAll(func(flag *pflag.Flag) {
switch flag.Name {
case "help", "version":
return
}
if flag.Hidden {
return
}
collected["--"+flag.Name] = true
})
}
collect(cmd.LocalNonPersistentFlags())
if root := cmd.Root(); cmd != root {
collect(cmd.PersistentFlags())
for parent := cmd.Parent(); parent != nil && parent != root; parent = parent.Parent() {
collect(parent.PersistentFlags())
}
}
names := make([]string, 0, len(collected))
for name := range collected {
names = append(names, name)
}
sort.Strings(names)
return names
}
// WriteError renders one failure. Machine mode writes a single document to
// stderr; human mode keeps the structured detail formatting and names the log
// file. The command boundary calls it exactly once per failure.
func (o *Output) WriteError(err error, logPath string) {
if err == nil || o == nil || o.cmd == nil {
return
}
if o.JSON() {
_ = o.writeJSON(o.cmd.ErrOrStderr(), newErrorDocument(o.cmd, err, logPath))
return
}
o.writeHumanError(err, logPath)
}
func (o *Output) writeHumanError(err error, logPath string) {
writer := o.cmd.ErrOrStderr()
var detail *report.Detail
if errors.As(err, &detail) && detail != nil {
report.RenderDetail(writer, detail, o.inv.Theme)
} else if IsUsageError(err) {
fmt.Fprintln(writer, o.inv.Theme.Failure("error:")+" "+err.Error())
hint := UsageHint(o.cmd, err)
if hint == "" {
hint = "Run --help for usage."
}
fmt.Fprintln(writer, " "+o.inv.Theme.Note("hint:")+" "+hint)
} else {
report.New(writer, o.inv.Theme).Error(err)
}
if logPath != "" {
fmt.Fprintf(writer, " %s %s\n", o.inv.Theme.Muted("log:"), logPath)
}
}
func newErrorDocument(cmd *cobra.Command, err error, logPath string) ErrorDocument {
document := ErrorDocument{
Schema: ErrorSchema,
Kind: ErrorKind,
Details: []string{},
Log: logPath,
}
detail := errorDetail(err)
if detail != nil {
document.Title = plain(detail.Title)
for _, line := range detail.Lines {
document.Details = append(document.Details, plain(line))
}
document.Hint = plain(detail.Hint)
document.Code = detail.Code
if document.Title == "" && detail.Cause != nil {
document.Title = plain(detail.Cause.Error())
}
document.Details = append(document.Details, joinedDetails(err, detail)...)
} else {
lines := errorLines(err)
if len(lines) > 0 {
document.Title = lines[0]
document.Details = append(document.Details, lines[1:]...)
}
}
if document.Title == "" {
document.Title = "command failed"
}
if document.Code == "" {
document.Code = errorCode(err)
}
if document.Hint == "" && document.Code == CodeInvalidInvocation {
document.Hint = UsageHint(cmd, err)
if document.Hint == "" {
document.Hint = "Run --help for usage."
}
}
document.Facts = FailureFacts(err)
return document
}
func errorDetail(err error) *report.Detail {
var detail *report.Detail
if errors.As(err, &detail) && detail != nil {
return detail
}
var failure *resolve.Failure
if errors.As(err, &failure) && failure != nil {
return resolutionDetail(failure)
}
return nil
}
// joinedDetails keeps secondary failures, such as a log file that fails to
// close, inside the primary failure's document instead of dropping them or
// writing a second document.
func joinedDetails(err error, primary *report.Detail) []string {
joined, ok := err.(interface{ Unwrap() []error })
if !ok {
return nil
}
var lines []string
for _, child := range joined.Unwrap() {
var detail *report.Detail
if errors.As(child, &detail) && detail == primary {
continue
}
if primary.Cause != nil && errors.Is(child, primary.Cause) {
continue
}
lines = append(lines, errorLines(child)...)
}
return lines
}
func errorLines(err error) []string {
raw := strings.Split(plain(err.Error()), "\n")
lines := make([]string, 0, len(raw))
for _, line := range raw {
line = strings.TrimSuffix(line, "\r")
if line == "" {
continue
}
lines = append(lines, line)
}
return lines
}
func errorCode(err error) string {
switch {
case IsUsageError(err):
return CodeInvalidInvocation
case errors.Is(err, context.Canceled),
errors.Is(err, context.DeadlineExceeded):
return CodeOperationCancelled
}
var failure *resolve.Failure
if errors.As(err, &failure) && failure != nil {
if failure.Aborted {
return CodeCommandFailed
}
if len(failure.Groups) == 1 &&
failure.Groups[0].Kind == resolve.RejectionVersionMismatch {
return CodeResolveNoMatchingBuild
}
return CodeResolveNoCompatibleBuild
}
return CodeCommandFailed
}
func plain(value string) string {
return ansi.Strip(value)
}
package cli
import (
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"io"
"github.com/mclucy/lucy/report"
"github.com/mclucy/lucy/terminal/progress"
"github.com/spf13/cobra"
)
// Output owns a command's streams and optional progress area.
// Documents and their human renderers stay with the command.
type Output struct {
cmd *cobra.Command
inv *Invocation
json JSONRendering
human io.Writer
renderer progress.Renderer
warnings []string
}
// NewQueryOutput sends human results to stdout.
func NewQueryOutput(cmd *cobra.Command, inv *Invocation) *Output {
return &Output{cmd: cmd, inv: inv, json: JSONMode(cmd), human: cmd.OutOrStdout()}
}
// NewOperationOutput sends human results to stderr.
func NewOperationOutput(cmd *cobra.Command, inv *Invocation) *Output {
return &Output{cmd: cmd, inv: inv, json: JSONMode(cmd), human: cmd.ErrOrStderr()}
}
func (o *Output) JSON() bool { return o.json != JSONNone }
// Human is silent in machine mode, even when a command reports a notice.
func (o *Output) Human() io.Writer {
if o.JSON() {
return io.Discard
}
return o.human
}
func (o *Output) writeJSON(w io.Writer, value any) error {
var options []json.Options
if o.json != JSONCompact {
options = []json.Options{jsontext.Multiline(true), jsontext.WithIndent(" ")}
}
data, err := json.Marshal(value, options...)
if err != nil {
return fmt.Errorf("encode output: %w", err)
}
if _, err := w.Write(append(data, '\n')); err != nil {
return fmt.Errorf("write output: %w", err)
}
return nil
}
// Warn records warnings for the result document and reports them in human mode.
func (o *Output) Warn(err error) {
if err == nil {
return
}
o.warnings = append(o.warnings, err.Error())
if !o.JSON() {
o.Close()
report.New(o.cmd.ErrOrStderr(), o.inv.Theme).Warn(err)
}
}
func (o *Output) Warnings() []string { return o.warnings }
// Info reports an informational human notice. Machine output carries the
// same fact in its document instead.
func (o *Output) Info(message string) {
if o.JSON() {
return
}
report.New(o.cmd.ErrOrStderr(), o.inv.Theme).Info(message)
}
// Error reports an error-level human notice that does not fail the command.
func (o *Output) Error(err error) {
if err == nil || o.JSON() {
return
}
o.Close()
report.New(o.cmd.ErrOrStderr(), o.inv.Theme).Error(err)
}
// Progress starts a renderer only when a command has work to narrate.
func (o *Output) Progress() progress.Renderer {
if o.renderer == nil {
o.renderer = o.newProgress()
}
return o.renderer
}
// RestartProgress rebuilds the renderer after a command suspended it, for
// example while a package manager owned the terminal.
func (o *Output) RestartProgress() {
o.renderer = o.newProgress()
}
func (o *Output) newProgress() progress.Renderer {
if o.JSON() {
return progress.Discard()
}
return progress.New(progress.Options{
Writer: o.cmd.ErrOrStderr(),
Theme: o.inv.Theme,
Terminal: o.inv.Stderr,
Interactive: o.inv.Interactive,
})
}
func (o *Output) Close() {
if o.renderer != nil {
o.renderer.Close()
}
}
package cli
import (
"context"
"errors"
"io"
"os"
"charm.land/huh/v2"
"github.com/charmbracelet/x/term"
"github.com/spf13/cobra"
)
// PromptGate decides whether one command may open an interactive selection.
// Machine-readable output and --no-interactive (which --no-style implies) turn
// prompts off: neither can be steered by terminal input. A confirmation flag
// such as --yes does not belong here because it answers a question rather
// than selecting the value the prompt would have selected.
type PromptGate struct {
cmd *cobra.Command
inv *Invocation
}
func NewPromptGate(cmd *cobra.Command, inv *Invocation) PromptGate {
return PromptGate{cmd: cmd, inv: inv}
}
// Input is the stream a prompt may read from.
func (g PromptGate) Input() io.Reader { return g.cmd.InOrStdin() }
// Output is the stream a prompt renders on. Prompts never own stdout.
func (g PromptGate) Output() io.Writer { return g.cmd.ErrOrStderr() }
// Available reports whether a prompt may read the terminal. A caller that
// still needs the selection fails with the flag that carries the same choice.
func (g PromptGate) Available() bool {
if !g.flagsAllow() || !g.inv.Interactive {
return false
}
stdin, ok := g.cmd.InOrStdin().(*os.File)
return ok && term.IsTerminal(stdin.Fd())
}
// flagsAllow reports whether the command's switches leave prompting enabled.
// Keeping it apart from the terminal test makes the switch behavior verifiable
// without a terminal.
func (g PromptGate) flagsAllow() bool {
if g.cmd == nil || JSONMode(g.cmd) != JSONNone {
return false
}
if noInteractive, _ := g.cmd.Flags().GetBool(FlagNoInteractive); noInteractive {
return false
}
if noStyle, _ := g.cmd.Flags().GetBool(FlagNoStyle); noStyle {
return false
}
return true
}
// Huh can report a cancelled context as a timeout instead of its cause.
func NormalizePromptError(ctx context.Context, err error) error {
if err == nil {
return nil
}
if cause := ctx.Err(); cause != nil {
return cause
}
if errors.Is(err, huh.ErrUserAborted) {
return context.Canceled
}
return err
}
package cli
import (
"fmt"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/spf13/cobra"
)
const (
flagRemoveLockedName = "locked"
flagRemoveOfflineName = "offline"
)
func NewRemoveCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
cmd := &cobra.Command{
Use: "remove",
Short: help.RemoveShort,
Args: cobra.MinimumNArgs(1),
Aliases: []string{"rm"},
PreRunE: func(cmd *cobra.Command, args []string) error {
ecosystem, _ := cmd.Flags().GetString(FlagEcosystem)
if ecosystem != "" && !catalog.Ecosystem(ecosystem).Valid() {
return fmt.Errorf("invalid ecosystem %q", ecosystem)
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return actionRemove(cmd, inv, deps, args)
},
}
installJSON.Bind(contracts, cmd)
cmd.Flags().Bool(
flagRemoveLockedName,
false,
"Do not resolve new versions; fail when the lock cannot satisfy",
)
cmd.Flags().Bool(
flagRemoveOfflineName,
false,
"Disallow network acquisition for any re-resolution this triggers",
)
AddEcosystemFlag(cmd)
return cmd
}
func actionRemove(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
args []string,
) error {
locked, _ := cmd.Flags().GetBool(flagRemoveLockedName)
offline, _ := cmd.Flags().GetBool(flagRemoveOfflineName)
ecosystemArg, _ := cmd.Flags().GetString(FlagEcosystem)
ecosystem := catalog.Ecosystem(ecosystemArg)
opts := install.Options{
Locked: locked,
Offline: offline,
WithOptional: false,
PublishManifest: false,
}
output := newInstallOutput(cmd, inv)
defer output.Close()
opts.Trace = output.Trace()
ctx, stop := CommandContext(cmd)
defer stop()
result, err := deps.Operations.Remove(ctx, inv.Directory, args, ecosystem, opts)
if err != nil {
return output.Failure(err)
}
return output.Render(result)
}
package cli
import (
"encoding/json"
"fmt"
"strings"
)
const (
maxReportMessageBytes = 512
maxReportStages = 32
maxReportStack = 32
maxReportCommandBytes = 256
)
// StageCount is one captured diagnostic stage and how often it repeated.
// Stages repeat because a command retries work, such as one resolution per
// candidate combination, so the count carries the retry width that the plain
// message loses.
type StageCount struct {
Code string `json:"code"`
Count int `json:"count"`
}
// ReportBody is the machine-readable body of a submitted failure report. It
// carries the failure code and the projected facts of the error tree, the same
// evidence the error document shows in machine mode, without the human title,
// detail lines, hint, or log path.
type ReportBody struct {
Command string `json:"command"`
Code string `json:"code"`
Message string `json:"message,omitempty"`
Stages []StageCount `json:"stages,omitempty"`
Facts map[string]any `json:"facts,omitempty"`
Stack []string `json:"stack,omitempty"`
}
// Encode renders the body as one line of JSON bounded for transport. The
// telemetry client sanitizes it afterwards, so this method only enforces the
// size limits that keep a report small.
func (b ReportBody) Encode() string {
b.Command = truncateFactText(b.Command, maxReportCommandBytes)
b.Code = truncateFactText(b.Code, 64)
b.Message = truncateFactText(collapseRepeatedLines(b.Message), maxReportMessageBytes)
if len(b.Stages) > maxReportStages {
b.Stages = b.Stages[:maxReportStages]
}
if len(b.Stack) > maxReportStack {
b.Stack = b.Stack[:maxReportStack]
}
payload, err := json.Marshal(b)
if err != nil {
return fmt.Sprintf("command=%s code=%s", b.Command, b.Code)
}
return string(payload)
}
// collapseRepeatedLines drops consecutive duplicate lines so a joined error
// that repeated the same cause five times reads as one cause.
func collapseRepeatedLines(text string) string {
if text == "" {
return ""
}
lines := strings.Split(text, "\n")
collapsed := lines[:0]
for _, line := range lines {
if len(collapsed) > 0 && collapsed[len(collapsed)-1] == line {
continue
}
collapsed = append(collapsed, line)
}
return strings.Join(collapsed, "\n")
}
// StageCounts groups captured stage messages by identity, keeping the order in
// which each first appeared.
func StageCounts(stages []string) []StageCount {
if len(stages) == 0 {
return nil
}
counts := make([]StageCount, 0, len(stages))
indexes := make(map[string]int, len(stages))
for _, stage := range stages {
if position, found := indexes[stage]; found {
counts[position].Count++
continue
}
indexes[stage] = len(counts)
counts = append(counts, StageCount{Code: stage, Count: 1})
}
return counts
}
package cli
import (
"fmt"
"reflect"
"strings"
"github.com/invopop/jsonschema"
"github.com/mclucy/lucy/report"
"github.com/spf13/cobra"
)
func NewSchemaCommand(
contracts *JSONContracts,
inv *Invocation,
) *cobra.Command {
cmd := &cobra.Command{
Use: "schema [COMMAND...]",
Short: "Describe a command's JSON result",
Hidden: true,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
output := NewQueryOutput(cmd, inv)
if !output.JSON() {
output.SetJSONRendering(JSONPretty)
}
failure, _ := cmd.Flags().GetBool("error")
if failure {
if len(args) != 0 {
return &report.Detail{
Title: "--error describes shared failures and does not accept a command path",
Code: CodeInvalidInvocation,
Hint: "Run lucy schema --error without a command path.",
}
}
schema := reflectJSONSchema([]reflect.Type{reflect.TypeFor[ErrorDocument]()})
schema.Title = "Lucy JSON failure"
return (JSONResult[*jsonschema.Schema]{}).Write(output, schema)
}
target := cmd.Root()
if len(args) != 0 {
found, remaining, err := cmd.Root().Find(args)
if err != nil || len(remaining) != 0 {
return &report.Detail{
Title: fmt.Sprintf("unknown command path %q", strings.Join(args, " ")),
Code: CodeInvalidInvocation,
Hint: "Run lucy schema to list supported command paths.",
}
}
target = found
}
if len(contracts.results[target]) == 0 {
paths := contracts.Paths(target)
if target == cmd.Root() || len(paths) != 0 {
return (JSONResult[[]string]{}).Write(output, paths)
}
return &report.Detail{
Title: fmt.Sprintf("%s does not provide a JSON result schema", target.CommandPath()),
Code: "usage.unsupported_output",
Hint: "Run lucy schema to list supported command paths.",
}
}
schema, err := contracts.Schema(target)
if err != nil {
return err
}
return (JSONResult[*jsonschema.Schema]{}).Write(output, schema)
},
}
cmd.Flags().Bool("error", false, "Describe the shared JSON failure document on stderr")
JSONResult[*jsonschema.Schema]{}.Bind(contracts, cmd)
JSONResult[[]string]{}.Bind(contracts, cmd)
contracts.Native(cmd)
return cmd
}
package cli
import (
"fmt"
"io"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/serverregistry"
"github.com/mclucy/lucy/terminal"
"github.com/mclucy/lucy/terminal/style"
"github.com/spf13/cobra"
)
// serverListJSON is the machine-readable `server list` result: every
// registered entry, including entries whose target cannot serve as a server.
var serverListJSON JSONResult[[]serverregistry.Entry]
// serverForgetJSON is the machine-readable result of forgetting one entry.
var serverForgetJSON JSONResult[serverForgetDocument]
// serverRenameJSON is the machine-readable result of one rename.
var serverRenameJSON JSONResult[serverRenameDocument]
type serverForgetDocument struct {
Path string `json:"path"`
}
type serverRenameDocument struct {
Previous string `json:"previous_name"`
Name string `json:"name"`
Path string `json:"path"`
Warnings []string `json:"warnings,omitempty"`
}
func NewServerCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
serverListCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: help.ServerListShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionServerList(cmd, inv, deps, args)
},
}
serverForgetCmd := &cobra.Command{
Use: "forget <path>",
Short: help.ServerForgetShort,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return actionServerForget(cmd, inv, deps, args)
},
}
serverRenameCmd := &cobra.Command{
Use: "rename <server> <name>",
Short: help.ServerRenameShort,
Args: cobra.ExactArgs(2),
ValidArgsFunction: completeServerRenameArguments,
RunE: func(cmd *cobra.Command, args []string) error {
return actionServerRename(cmd, inv, deps, args)
},
}
serverCmd := &cobra.Command{
Use: "server",
Short: help.ServerShort,
Args: cobra.NoArgs,
}
serverListJSON.Bind(contracts, serverListCmd)
serverForgetJSON.Bind(contracts, serverForgetCmd)
serverRenameJSON.Bind(contracts, serverRenameCmd)
serverCmd.AddCommand(serverListCmd, serverForgetCmd, serverRenameCmd)
return serverCmd
}
func completeServerRenameArguments(
cmd *cobra.Command,
args []string,
toComplete string,
) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return completeServerNames(cmd, args, toComplete)
}
// actionServerList reports the registry as it is. Unavailable targets stay in
// the listing with their state and problem instead of being pruned or hidden.
func actionServerList(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) error {
output := NewQueryOutput(cmd, inv)
registry, err := deps.ServerRegistry()
if err != nil {
return err
}
entries, err := registry.List()
if err != nil {
return err
}
if output.JSON() {
if entries == nil {
entries = []serverregistry.Entry{}
}
return serverListJSON.Write(output, entries)
}
return writeServerList(output.Human(), entries, inv.Theme, inv.Stdout.Width)
}
// writeServerList renders one aligned row per entry. A name column that is
// empty in the registry stays visible as "-", and a problem wraps below the
// entry instead of hiding the path.
func writeServerList(
out io.Writer,
entries []serverregistry.Entry,
theme style.Theme,
width int,
) error {
if len(entries) == 0 {
_, err := fmt.Fprintln(out, "No servers registered")
return err
}
facts := make([]terminal.Fact, 0, len(entries))
for _, entry := range entries {
name := entry.Name
if name == "" {
name = "-"
}
value := entry.State + " " + entry.Path
if entry.Problem != "" {
value += "\n" + theme.Muted(entry.Problem)
}
facts = append(facts, terminal.Fact{Key: name, Value: value})
}
_, err := fmt.Fprintln(out, terminal.RenderFacts(facts, width, theme))
return err
}
// actionServerForget removes a registry entry whose recorded server directory
// is gone. The registry decides whether the target is confirmed missing; the
// command reports the forgotten path and never deletes server data.
func actionServerForget(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
args []string,
) error {
output := NewOperationOutput(cmd, inv)
registry, err := deps.ServerRegistry()
if err != nil {
return err
}
entry, err := registry.Forget(args[0])
if err != nil {
return err
}
if output.JSON() {
return serverForgetJSON.Write(output, serverForgetDocument{Path: entry.Path})
}
_, err = fmt.Fprintf(output.Human(), "forgot server %s\n", entry.Path)
return err
}
// actionServerRename changes the manifest name of one registered server. The
// new name may already be in use; registration reports that ambiguity as a
// warning instead of rejecting the rename.
func actionServerRename(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
args []string,
) error {
output := NewOperationOutput(cmd, inv)
registry, err := deps.ServerRegistry()
if err != nil {
return err
}
entry, err := registry.Resolve(args[0])
if err != nil {
return err
}
name, err := document.NormalizeName(args[1])
if err != nil {
return err
}
previous, err := deps.Operations.RenameServer(entry.Path, name)
if err != nil {
return err
}
registerWorkspace(entry.Path, output, inv, deps)
result := serverRenameDocument{
Previous: previous,
Name: name,
Path: entry.Path,
Warnings: output.Warnings(),
}
if output.JSON() {
return serverRenameJSON.Write(output, result)
}
out := output.Human()
switch {
case previous == "":
_, err = fmt.Fprintf(out, "named server %s\n", name)
case previous == name:
_, err = fmt.Fprintf(out, "server is already named %s\n", name)
default:
_, err = fmt.Fprintf(out, "renamed %s to %s\n", previous, name)
}
return err
}
package cli
import (
"fmt"
"path/filepath"
"strings"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/internal/workspacewrite"
"github.com/mclucy/lucy/serverregistry"
"github.com/spf13/cobra"
)
const FlagServer = "server"
func RegisterServerSelection(root *cobra.Command) {
root.PersistentFlags().String(FlagServer, "", "Run the command in the registered server's directory")
_ = root.RegisterFlagCompletionFunc(FlagServer, completeServerNames)
}
// ResolveServer applies the parsed --server selection to inv. It resolves the
// registered name to its absolute directory; the process working directory is
// never changed. Completion commands are not server selections.
func ResolveServer(
cmd *cobra.Command,
registry *serverregistry.Registry,
inv *Invocation,
) error {
if cmd.Name() == "__complete" || cmd.Name() == "__completeNoDesc" {
return nil
}
flags := cmd.Root().PersistentFlags()
if !flags.Changed(FlagServer) {
return nil
}
name, err := flags.GetString(FlagServer)
if err != nil {
return err
}
if registry == nil {
return fmt.Errorf("select server %q: server registry is unavailable", name)
}
entry, err := registry.Resolve(name)
if err != nil {
return err
}
inv.Directory = entry.Path
inv.Logger.Debug(
"server selected",
"name", name,
"directory", entry.Path,
)
return nil
}
func completeServerNames(_ *cobra.Command, _ []string, prefix string) ([]string, cobra.ShellCompDirective) {
registry, err := serverregistry.New()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
entries, err := registry.List()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
seen := make(map[string]bool)
var names []string
for _, entry := range entries {
if entry.State != serverregistry.StateAvailable || entry.Name == "" || !strings.HasPrefix(entry.Name, prefix) || seen[entry.Name] {
continue
}
seen[entry.Name] = true
names = append(names, entry.Name+"\t"+entry.Path)
}
return names, cobra.ShellCompDirectiveNoFileComp
}
func registerWorkspace(
root string,
output *Output,
inv *Invocation,
deps *Dependencies,
) {
if err := workspacewrite.SafePath(root, ".lucy/"+workspacewrite.LockFileName); err != nil {
output.Warn(fmt.Errorf("server registration failed: %w", err))
return
}
// Do not recreate .lucy if deinit removed it after the operation returned.
writer, err := fsutil.TryLockFile(filepath.Join(root, ".lucy", workspacewrite.LockFileName))
if err != nil {
output.Warn(fmt.Errorf("server registration failed: %w", err))
return
}
defer func() {
if err := writer.Close(); err != nil {
output.Warn(fmt.Errorf("release server registration lock: %w", err))
}
}()
registry, err := deps.ServerRegistry()
if err != nil {
output.Warn(fmt.Errorf("server registration skipped: %w", err))
return
}
duplicates, err := registry.Register(root)
if err != nil {
output.Warn(fmt.Errorf("server registration failed: %w", err))
return
}
inv.Logger.Debug("server registration checked", "directory", root)
for _, duplicate := range duplicates {
output.Warn(fmt.Errorf(
"server name %q is also registered at %s; --server cannot select an ambiguous name",
duplicate.Name,
duplicate.Path,
))
}
}
package cli
import (
"errors"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/javainstall"
"github.com/mclucy/lucy/operation"
"github.com/mclucy/lucy/serverregistry"
"github.com/mclucy/lucy/upstream/routing"
)
// Dependencies is the concrete service assembly one invocation uses. The root
// wiring fills it after server selection, so services observe the selected
// workspace rather than the process working directory. Every field is an
// explicitly typed service; commands read only the fields they declared in
// their constructor.
type Dependencies struct {
// Operations runs workspace operations through the configured installer.
Operations *operation.Service
// Files tracks and restores files in the selected workspace.
Files *filetrack.Service
// Router answers upstream metadata requests.
Router *routing.Router
// Network fetches and caches upstream responses.
Network *cache.Client
// Registry resolves registered server names and paths. RegistryError
// reports why the registry could not be constructed; commands that need
// the registry return it instead of dereferencing a nil Registry.
Registry *serverregistry.Registry
RegistryError error
// JavaHost plans and executes Java installations for this invocation.
JavaHost *javainstall.Host
}
// ServerRegistry returns the invocation's registry or the reason it is
// unavailable. Commands that need the registry call this instead of
// dereferencing Registry directly.
func (d *Dependencies) ServerRegistry() (*serverregistry.Registry, error) {
if d.Registry != nil {
return d.Registry, nil
}
if d.RegistryError != nil {
return nil, d.RegistryError
}
return nil, errors.New("server registry is unavailable")
}
package cli
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/mclucy/lucy/catalog"
documentpkg "github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/report"
"github.com/mclucy/lucy/terminal"
versionpkg "github.com/mclucy/lucy/version"
"github.com/mclucy/lucy/workspace"
"github.com/mclucy/lucy/workspace/discovery"
"github.com/mclucy/lucy/workspace/identify"
"github.com/spf13/cobra"
)
const flagStatusFromLockName = "from-lock"
var statusJSON JSONResult[statusDocument]
func NewStatusCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
statusCmd := &cobra.Command{
Use: "status",
Short: help.StatusShort,
Args: cobra.NoArgs,
Aliases: []string{"s"},
PreRunE: func(cmd *cobra.Command, args []string) error {
logo, _ := cmd.Flags().GetString(FlagLogo)
if _, ok := terminal.ParseStatusLogoMode(logo); !ok {
return fmt.Errorf("--logo must be one of none, small, large")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return actionStatus(cmd, inv, deps, args)
},
}
statusJSON.Bind(contracts, statusCmd)
AddLongFlag(statusCmd)
AddLogoFlag(statusCmd)
statusCmd.Flags().Bool(
flagStatusFromLockName,
false,
"Render the server identity and packages from lucy-lock.yaml instead of the live directory",
)
_ = statusCmd.RegisterFlagCompletionFunc(
FlagLogo,
func(cmd *cobra.Command, args []string, toComplete string) (
[]string,
cobra.ShellCompDirective,
) {
candidates := FilterByPrefix(StaticStatusLogoCandidates(), toComplete)
return ToCobraCompletions(candidates), cobra.ShellCompDirectiveNoFileComp
},
)
return statusCmd
}
func actionStatus(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) error {
long, _ := cmd.Flags().GetBool(FlagLong)
logo, _ := cmd.Flags().GetString(FlagLogo)
logoMode, _ := terminal.ParseStatusLogoMode(logo)
output := NewQueryOutput(cmd, inv)
fromLock, _ := cmd.Flags().GetBool(flagStatusFromLockName)
if fromLock {
return actionStatusFromLock(cmd.Context(), output, inv, long, logoMode)
}
return actionStatusLive(cmd, output, inv, deps, long, logoMode)
}
// actionStatusLive observes the working directory and reports the runtime and
// packages found there. Selection uncertainty is part of the document, not a
// command failure: an empty, ambiguous, or declared-missing workspace still
// prints a useful status.
func actionStatusLive(
cmd *cobra.Command,
output *Output,
inv *Invocation,
deps *Dependencies,
long bool,
logoMode terminal.StatusLogoMode,
) error {
ctx, stop := CommandContext(cmd)
defer stop()
root := inv.Directory
observed, identifyWarnings := identify.Packages(
ctx,
workspace.Observe(
root,
discovery.Request{Policy: discovery.PolicyPreferModified},
),
deps.Router.ResolveArtifactByHash,
)
if err := ctx.Err(); err != nil {
return err
}
reportObservationFindings(observed, output)
for _, warning := range identifyWarnings {
output.Warn(warning)
}
if failure := statusSelectionFailure(observed); failure != nil {
recordWorkspaceTelemetryFailure(inv)
return failure
}
document := newLiveStatusDocument(observed, root)
if document.Server != nil &&
(document.Server.Product.Version == "" ||
document.Server.Product.Version == versionpkg.Unknown.String()) {
// Paperclip carries no build number in its metadata, and an installed
// core may be renamed. The lock records the adopted build.
if lock, err := documentpkg.LockFile(root).Read(); err == nil && lock.Server.CoreVersion != "" {
document.Server.Product.Version = lock.Server.CoreVersion.String()
}
}
if document.MCDR == nil ||
document.MCDR.Version == "" ||
document.MCDR.Version == versionpkg.Unknown.String() {
// MCDReforged is provisioned locally, so a missing global executable
// leaves the observation unknown. The lock carries the managed version.
if lock, err := documentpkg.LockFile(root).Read(); err == nil && lock.MCDR != nil {
document.MCDR = &statusMCDR{
Directory: statusRelativePath(root, lock.MCDR.Directory),
Version: lock.MCDR.Version.String(),
}
}
}
recordWorkspaceTelemetry(inv, observed, document)
document.Warnings = append(document.Warnings, output.Warnings()...)
return writeStatusDocument(ctx, output, inv, document, long, logoMode)
}
// actionStatusFromLock reports only the facts persisted in lucy-lock.yaml.
// The lock cannot prove whether the server is running or which entry point an
// installation would use, so the document leaves both unobserved.
func actionStatusFromLock(
ctx context.Context,
output *Output,
inv *Invocation,
long bool,
logoMode terminal.StatusLogoMode,
) error {
root := inv.Directory
lock, err := documentpkg.LockFile(root).Read()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return &report.Detail{
Title: fmt.Sprintf("no %s to render", documentpkg.LockFilename),
Lines: []string{
"The workspace has no lock file yet.",
},
Hint: "Run lucy install first.",
Code: statusErrorLockMissing,
Cause: err,
}
}
return &report.Detail{
Title: "cannot read " + documentpkg.LockFilename,
Lines: []string{err.Error()},
Code: statusErrorReadFailed,
Cause: err,
}
}
return writeStatusDocument(
ctx,
output,
inv,
newLockStatusDocument(root, lock),
long,
logoMode,
)
}
// statusSelectionFailure reports only selection outcomes that make the
// observation unusable. An empty workspace, an unresolved declaration, and
// competing candidates remain successful reports; discovery already turns
// them into document warnings.
func statusSelectionFailure(observed *workspace.Workspace) error {
if observed == nil {
return nil
}
failure := observed.SelectionError()
if failure == nil {
return nil
}
var typed *discovery.SelectionError
if errors.As(failure, &typed) {
switch typed.State {
case discovery.SelectionNone,
discovery.SelectionAmbiguous,
discovery.SelectionMissingDeclared:
return nil
}
}
return &report.Detail{
Title: "cannot select a server runtime",
Lines: []string{failure.Error()},
Code: statusErrorSelectionFailed,
Cause: failure,
}
}
func writeStatusDocument(
ctx context.Context,
output *Output,
inv *Invocation,
document statusDocument,
long bool,
logoMode terminal.StatusLogoMode,
) error {
if err := ctx.Err(); err != nil {
return err
}
if output.JSON() {
return statusJSON.Write(output, document)
}
_, err := fmt.Fprintln(
output.Human(),
renderStatus(document, long, logoMode, inv.Stdout, inv.Theme),
)
if err != nil {
return fmt.Errorf("write status: %w", err)
}
return nil
}
// selectedRuntimeComponents presents the runtime components the status
// document reports. Discovery keeps the primary identity separate and stores
// only additional evidence, so a vanilla primary contributes its Minecraft
// component here, and a loader primary contributes its loader component.
func selectedRuntimeComponents(
selection *discovery.Selection,
) []catalog.VersionedRef {
if selection == nil || !selection.IsValid() {
return []catalog.VersionedRef{}
}
identity := selection.Installation.Identity
components := make([]catalog.VersionedRef, 0, len(identity.Components)+2)
components = append(components, identity.Components...)
primary := identity.Primary
switch strings.ToLower(primary.Name.String()) {
case "minecraft":
components = ensureRuntimeComponent(components, catalog.VersionedRef{
Ecosystem: catalog.EcosystemMinecraft,
Name: "minecraft",
Version: primary.Version,
})
case "fabric", "fabric-loader":
components = ensureRuntimeComponent(components, catalog.VersionedRef{
Ecosystem: catalog.EcosystemFabric,
Name: "fabric-loader",
Version: primary.Version,
})
case "forge":
components = ensureRuntimeComponent(components, catalog.VersionedRef{
Ecosystem: catalog.EcosystemForge,
Name: "forge",
Version: primary.Version,
})
case "neoforge":
components = ensureRuntimeComponent(components, catalog.VersionedRef{
Ecosystem: catalog.EcosystemNeoForge,
Name: "neoforge",
Version: primary.Version,
})
}
if !hasRuntimeComponent(components, catalog.EcosystemMinecraft, "minecraft") {
components = ensureRuntimeComponent(components, catalog.VersionedRef{
Ecosystem: catalog.EcosystemMinecraft,
Name: "minecraft",
Version: identity.MinecraftVersion(),
})
}
return components
}
func ensureRuntimeComponent(
components []catalog.VersionedRef,
component catalog.VersionedRef,
) []catalog.VersionedRef {
if component.Version == "" || component.Version == versionpkg.Unknown {
return components
}
if hasRuntimeComponent(components, component.Ecosystem, component.Name.String()) {
return components
}
return append(components, component)
}
func hasRuntimeComponent(
components []catalog.VersionedRef,
ecosystem catalog.Ecosystem,
name string,
) bool {
for _, component := range components {
if component.Ecosystem == ecosystem && component.Name == catalog.Name(name) {
return true
}
}
return false
}
package cli
import (
"path/filepath"
"strings"
"github.com/mclucy/lucy/catalog"
documentpkg "github.com/mclucy/lucy/document"
serverpkg "github.com/mclucy/lucy/server"
versionpkg "github.com/mclucy/lucy/version"
"github.com/mclucy/lucy/workspace"
"github.com/mclucy/lucy/workspace/discovery"
)
const (
statusDocumentSchema = 1
statusDocumentKind = "status"
statusSourceLive = "live"
statusSourceLock = "lock"
statusActivityRunning = "running"
statusActivityStopped = "stopped"
statusActivityUnknown = "unknown"
statusErrorLockMissing = "status.lock_missing"
statusErrorReadFailed = "status.read_failed"
statusErrorSelectionFailed = "status.selection_failed"
)
// statusDocument is the one wire format both live observation and lock
// rendering produce. Entries are never folded here; the human renderer bounds
// its own view.
type statusDocument struct {
Schema int `json:"schema"`
Kind string `json:"kind"`
Source string `json:"source"`
Root string `json:"root"`
Server *statusServer `json:"server"`
Packages []statusPackage `json:"packages"`
MCDR *statusMCDR `json:"mcdr,omitempty"`
Warnings []string `json:"warnings"`
}
type statusServer struct {
Product statusProduct `json:"product"`
Minecraft string `json:"minecraft"`
EntryPoint string `json:"entry_point"`
Activity string `json:"activity"`
Components []statusComponent `json:"components"`
CompatibleEcosystems []statusEcosystem `json:"compatible_ecosystems"`
PackageDirectories []string `json:"package_directories"`
}
type statusProduct struct {
Name string `json:"name"`
Version string `json:"version"`
Ecosystem string `json:"ecosystem"`
}
type statusComponent struct {
Name string `json:"name"`
Version string `json:"version"`
Provider string `json:"provider"`
Ecosystem string `json:"ecosystem"`
}
type statusEcosystem struct {
Ecosystem string `json:"ecosystem"`
Compatibility string `json:"compatibility"`
}
type statusPackage struct {
Name string `json:"name"`
Version string `json:"version"`
Provider string `json:"provider"`
Ecosystem string `json:"ecosystem"`
Environment string `json:"environment"`
Path string `json:"path"`
}
type statusMCDR struct {
Directory string `json:"directory"`
Version string `json:"version"`
}
func newStatusDocument(source, root string) statusDocument {
return statusDocument{
Schema: statusDocumentSchema,
Kind: statusDocumentKind,
Source: source,
Root: root,
Packages: []statusPackage{},
Warnings: []string{},
}
}
func newLiveStatusDocument(
observed *workspace.Workspace,
root string,
) statusDocument {
document := newStatusDocument(statusSourceLive, root)
if observed == nil {
return document
}
selection := observed.Runtime()
if selection != nil {
document.Server = statusServerFromObservation(observed, selection, root)
}
document.Packages = statusPackagesFromObservation(
observed.Packages,
selection,
root,
)
document.MCDR = statusMCDRFromObservation(observed, root)
return document
}
func statusServerFromObservation(
observed *workspace.Workspace,
selection *discovery.Selection,
root string,
) *statusServer {
entryPoint := ""
if selection.EntryPoint != nil {
entryPoint = statusRelativePath(root, selection.EntryPoint.Path)
}
activity := statusActivityStopped
if observed.Running() {
activity = statusActivityRunning
}
return &statusServer{
Product: statusProductFromSelection(selection),
Minecraft: selection.MinecraftVersion().String(),
EntryPoint: entryPoint,
Activity: activity,
Components: statusComponents(selectedRuntimeComponents(selection)),
CompatibleEcosystems: statusCompatibleEcosystems(observed.ServedEcosystems()),
PackageDirectories: statusRelativePaths(root, observed.PackageDirs()),
}
}
// statusProductFromSelection reports the catalogued product identity rather
// than the observed artifact identity. A launcher that records its full
// filename as its version still reports the short product name.
func statusProductFromSelection(
selection *discovery.Selection,
) statusProduct {
if selection == nil || selection.Installation == nil {
return statusProduct{}
}
identity := selection.Installation.Identity
primary := identity.Primary
name := primary.Name.String()
ecosystem := primary.Ecosystem
if match, ok := serverpkg.NormalizeProduct(primary); ok {
name = match.Product.String()
if ecosystem == catalog.EcosystemUnspecified {
ecosystem = match.Ecosystem
}
}
if ecosystem == catalog.EcosystemUnspecified {
ecosystem = identity.Ecosystem()
}
return statusProduct{
Name: name,
Version: primary.Version.String(),
Ecosystem: ecosystem.String(),
}
}
func statusComponents(
components []catalog.VersionedRef,
) []statusComponent {
result := make([]statusComponent, 0, len(components))
for _, component := range components {
result = append(result, statusComponent{
Name: component.Name.String(),
Version: component.Version.String(),
Provider: component.Provider.String(),
Ecosystem: component.Ecosystem.String(),
})
}
return result
}
func statusCompatibleEcosystems(
offers []workspace.ServedEcosystem,
) []statusEcosystem {
result := make([]statusEcosystem, 0, len(offers))
for _, offer := range offers {
result = append(result, statusEcosystem{
Ecosystem: offer.Ecosystem.String(),
Compatibility: string(offer.Compatibility),
})
}
return result
}
// statusPackagesFromObservation projects installed content packages. Runtime
// components belong in the server document, and server products are not
// content packages, so both are excluded from the package list.
func statusPackagesFromObservation(
packages []workspace.DiscoveredPackage,
selection *discovery.Selection,
root string,
) []statusPackage {
result := make([]statusPackage, 0, len(packages))
runtimeComponents := map[string]struct{}{}
if selection != nil {
for _, component := range selectedRuntimeComponents(selection) {
runtimeComponents[component.StringBase()] = struct{}{}
}
}
for _, pkg := range packages {
if _, isComponent := runtimeComponents[pkg.ID.StringBase()]; isComponent {
continue
}
if serverpkg.IsProduct(pkg.ID) {
continue
}
environment := serverpkg.EnvironmentServer
if pkg.ID.Ecosystem == catalog.EcosystemMCDR {
environment = serverpkg.EnvironmentMCDR
}
result = append(result, statusPackage{
Name: pkg.ID.Name.String(),
Version: pkg.ID.Version.String(),
Provider: pkg.ID.Provider.String(),
Ecosystem: pkg.ID.Ecosystem.String(),
Environment: string(environment),
Path: statusRelativePath(root, pkg.Path),
})
}
return result
}
func statusMCDRFromObservation(
observed *workspace.Workspace,
root string,
) *statusMCDR {
if observed == nil || observed.Wrappers.MCDR == nil {
return nil
}
return &statusMCDR{
Directory: statusRelativePath(root, root),
Version: observed.Wrappers.MCDR.Version.String(),
}
}
func newLockStatusDocument(
root string,
lock *documentpkg.Lock,
) statusDocument {
document := newStatusDocument(statusSourceLock, root)
if lock == nil {
return document
}
document.Server = &statusServer{
Product: statusProduct{
Name: lock.Server.Product.String(),
Version: lock.Server.CoreVersion.String(),
Ecosystem: statusLockProductEcosystem(lock).String(),
},
Minecraft: lock.Server.Minecraft.String(),
EntryPoint: "",
Activity: statusActivityUnknown,
Components: statusLockComponents(lock),
CompatibleEcosystems: statusLockCompatibleEcosystems(lock),
PackageDirectories: statusLockPackageDirectories(root, lock),
}
if lock.MCDR != nil {
document.MCDR = &statusMCDR{
Directory: statusRelativePath(root, lock.MCDR.Directory),
Version: lock.MCDR.Version.String(),
}
}
document.Packages = make([]statusPackage, 0, len(lock.Packages))
for i := range lock.Packages {
pkg := &lock.Packages[i]
document.Packages = append(document.Packages, statusPackage{
Name: statusLockPackageName(pkg),
Version: pkg.Version.String(),
Provider: pkg.Provider.String(),
Ecosystem: pkg.Ecosystem.String(),
Environment: string(pkg.Environment),
Path: statusLockPackagePath(root, lock, pkg),
})
}
return document
}
// statusLockComponents reports the runtime components the lock proves. The
// lock records the core version and the Minecraft version, so loader cores
// contribute their loader component exactly as live selection does.
func statusLockComponents(lock *documentpkg.Lock) []statusComponent {
components := make([]statusComponent, 0, 2)
components = append(components, statusComponent{
Name: "minecraft",
Version: lock.Server.Minecraft.String(),
Ecosystem: catalog.EcosystemMinecraft.String(),
})
var (
name string
ecosystem catalog.Ecosystem
)
switch lock.Server.Product {
case serverpkg.ProductFabric:
name, ecosystem = "fabric-loader", catalog.EcosystemFabric
case serverpkg.ProductForge:
name, ecosystem = "forge", catalog.EcosystemForge
case serverpkg.ProductNeoForge:
name, ecosystem = "neoforge", catalog.EcosystemNeoForge
default:
return components
}
if !statusConcreteVersion(lock.Server.CoreVersion) {
return components
}
return append(components, statusComponent{
Name: name,
Version: lock.Server.CoreVersion.String(),
Ecosystem: ecosystem.String(),
})
}
// Only enabled packages can contribute compatibility evidence.
func statusLockCompatibleEcosystems(
lock *documentpkg.Lock,
) []statusEcosystem {
result := make([]statusEcosystem, 0, 3)
seen := make(map[catalog.Ecosystem]bool, 3)
add := func(ecosystem catalog.Ecosystem, compatibility workspace.Compatibility) {
if !ecosystem.Valid() ||
ecosystem == catalog.EcosystemUnspecified ||
ecosystem == catalog.EcosystemMinecraft ||
seen[ecosystem] {
return
}
seen[ecosystem] = true
result = append(result, statusEcosystem{
Ecosystem: ecosystem.String(),
Compatibility: string(compatibility),
})
}
native := statusLockNativeEcosystems(lock)
for _, offer := range native {
add(offer.Ecosystem, offer.Compatibility)
}
productEcosystem := statusLockProductEcosystem(lock)
connectorBridge := statusLockHasConnectorBridge(lock)
for i := range lock.Packages {
pkg := &lock.Packages[i]
if !pkg.Enabled() || pkg.Environment != serverpkg.EnvironmentServer {
continue
}
compatibility := workspace.CompatFull
if connectorBridge &&
pkg.Ecosystem == catalog.EcosystemFabric &&
(productEcosystem == catalog.EcosystemForge ||
productEcosystem == catalog.EcosystemNeoForge) {
compatibility = workspace.CompatDegraded
}
add(pkg.Ecosystem, compatibility)
}
if connectorBridge &&
(productEcosystem == catalog.EcosystemForge ||
productEcosystem == catalog.EcosystemNeoForge) {
add(catalog.EcosystemFabric, workspace.CompatDegraded)
}
if seen[catalog.EcosystemPaper] {
add(catalog.EcosystemBukkit, workspace.CompatFull)
}
return result
}
// statusLockHasConnectorBridge reports the same package roster the live
// workspace uses to offer degraded Fabric compatibility. Locked packages may
// name the project through a binding or only expose the module identity.
func statusLockHasConnectorBridge(lock *documentpkg.Lock) bool {
for i := range lock.Packages {
pkg := &lock.Packages[i]
if !pkg.Enabled() || pkg.Environment != serverpkg.EnvironmentServer {
continue
}
if pkg.Ecosystem != catalog.EcosystemForge &&
pkg.Ecosystem != catalog.EcosystemNeoForge {
continue
}
if statusIsConnectorName(statusLockPackageName(pkg)) {
return true
}
for _, module := range pkg.Modules {
if statusIsConnectorName(module.ID) {
return true
}
}
}
return false
}
func statusIsConnectorName(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "sinytra-connector", "connector", "connectormod":
return true
default:
return false
}
}
// statusLockPackageDirectories lists the directories locked packages land in.
// A runtime whose ecosystem has a conventional content directory contributes
// that directory even when the lock selects no packages.
func statusLockPackageDirectories(
root string,
lock *documentpkg.Lock,
) []string {
result := make([]string, 0, 4)
seen := make(map[string]bool, 4)
add := func(directory string) {
if directory == "" {
return
}
relative := statusRelativePath(root, directory)
if relative == "" || seen[relative] {
return
}
seen[relative] = true
result = append(result, relative)
}
switch statusLockProductEcosystem(lock) {
case catalog.EcosystemFabric, catalog.EcosystemForge, catalog.EcosystemNeoForge:
add(filepath.Join(lock.Server.Directory, "mods"))
case catalog.EcosystemBukkit, catalog.EcosystemPaper:
add(filepath.Join(lock.Server.Directory, "plugins"))
}
for i := range lock.Packages {
add(statusLockPackageDirectory(lock, &lock.Packages[i]))
}
return result
}
// statusLockPackagePath reports the destination the lock's layout assigns to a
// package. It mirrors the install and filetrack placement rules: server
// packages live under mods/, Bukkit-family packages under plugins/, and MCDR
// packages under the MCDR wrapper's plugins/ directory.
func statusLockPackagePath(
root string,
lock *documentpkg.Lock,
pkg *documentpkg.Package,
) string {
return statusRelativePath(root, statusLockPackageDestination(lock, pkg))
}
func statusLockPackageDirectory(
lock *documentpkg.Lock,
pkg *documentpkg.Package,
) string {
destination := statusLockPackageDestination(lock, pkg)
if destination == "" {
return ""
}
return filepath.Dir(destination)
}
func statusLockPackageDestination(
lock *documentpkg.Lock,
pkg *documentpkg.Package,
) string {
directory := lock.Server.Directory
folder := "mods"
switch pkg.Environment {
case serverpkg.EnvironmentMCDR:
if lock.MCDR == nil {
return ""
}
directory, folder = lock.MCDR.Directory, "plugins"
case serverpkg.EnvironmentServer:
if pkg.Ecosystem == catalog.EcosystemBukkit ||
pkg.Ecosystem == catalog.EcosystemPaper {
folder = "plugins"
}
default:
return ""
}
return filepath.Join(directory, folder, pkg.Filename)
}
// statusLockPackageName prefers the project a lock binding names. The lock's
// ProjectID is the opaque provider identity and stays the fallback when no
// binding reference is readable.
func statusLockPackageName(pkg *documentpkg.Package) string {
var fallback string
for _, binding := range pkg.Requirements {
_, project, err := documentpkg.ParseReference(binding.Reference)
if err != nil || project == "" {
continue
}
if binding.Enabled {
return project
}
if fallback == "" {
fallback = project
}
}
if fallback != "" {
return fallback
}
if pkg.ProjectID != "" {
return pkg.ProjectID
}
return strings.TrimSuffix(pkg.Filename, filepath.Ext(pkg.Filename))
}
func statusConcreteVersion(version versionpkg.Version) bool {
return version != "" && !version.CanInfer() && !version.IsInvalid()
}
// Native product support must not narrow to the packages currently declared.
func statusLockNativeEcosystems(lock *documentpkg.Lock) []workspace.ServedEcosystem {
components := make([]catalog.VersionedRef, 0)
for _, input := range lock.Server.Inputs {
for _, module := range input.Modules {
components = append(components, catalog.VersionedRef{
Name: catalog.Name(module.ID),
Version: module.Version,
Ecosystem: module.Ecosystem,
})
}
}
return workspace.ProductServedEcosystems(
catalog.VersionedRef{
Name: catalog.Name(lock.Server.Product.String()),
Version: lock.Server.CoreVersion,
},
components,
)
}
func statusLockProductEcosystem(lock *documentpkg.Lock) catalog.Ecosystem {
match, ok := serverpkg.NormalizeProduct(catalog.VersionedRef{
Name: catalog.Name(lock.Server.Product.String()),
})
if ok && match.Ecosystem != catalog.EcosystemUnspecified {
return match.Ecosystem
}
return ecosystemFromLock(lock)
}
// statusRelativePath reports a path relative to the document root. Absolute
// paths outside the root stay relative so the document never mixes roots.
func statusRelativePath(root, path string) string {
if path == "" {
return ""
}
if !filepath.IsAbs(path) {
return filepath.ToSlash(filepath.Clean(path))
}
relative, err := filepath.Rel(root, path)
if err != nil {
return filepath.ToSlash(path)
}
return filepath.ToSlash(relative)
}
func statusRelativePaths(root string, paths []string) []string {
result := make([]string, 0, len(paths))
for _, path := range paths {
relative := statusRelativePath(root, path)
if relative == "" {
continue
}
result = append(result, relative)
}
return result
}
package cli
import (
"cmp"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/mclucy/lucy/catalog"
serverpkg "github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/terminal"
"github.com/mclucy/lucy/terminal/style"
versionpkg "github.com/mclucy/lucy/version"
"github.com/mclucy/lucy/workspace"
)
const (
// statusViewPackageGridLines bounds the default package grid. The total
// package count stays visible, and the dim tail marker reports the rest.
statusViewPackageGridLines = 6
statusViewColumnGap = 2
)
// renderStatus renders the human status view from the same document that
// produces JSON. The caller supplies the width; process globals only decide
// whether the logo may appear at all.
func renderStatus(
document statusDocument,
long bool,
logoMode terminal.StatusLogoMode,
term style.Terminal,
theme style.Theme,
) string {
width := term.Width
if width <= 0 {
width = 80
}
logo := resolveStatusViewLogo(document, theme)
layout := terminal.NegotiateStatusLayout(
width,
logo.largeWidth(),
logo.smallWidth(),
term.IsTTY,
logoMode,
)
if logo.empty() {
layout = terminal.StatusLayoutParams{
Mode: terminal.LayoutInfoOnly,
InfoWidth: width,
}
}
if layout.InfoWidth <= 0 {
layout.InfoWidth = width
}
infoWidth := max(1, layout.InfoWidth-2)
packages := statusViewPackageGroups(document.Packages)
facts := statusViewFacts(document, packages, long, infoWidth, theme)
info := terminal.RenderFactGroups(facts, infoWidth, theme)
if long {
for index, group := range packages {
separator := "\n\n"
if index == 0 && len(facts) > 1 && len(facts[1]) > 0 {
separator = "\n"
}
info += separator + group.title + " " +
theme.Muted(fmt.Sprintf("(%d)", len(group.packages)))
info += "\n" + statusViewPackageTable(
document.Root,
group.packages,
infoWidth,
theme,
)
}
}
info = " " + strings.ReplaceAll(info, "\n", "\n ")
return statusViewCompose(info, logo, layout, logoMode)
}
type statusViewPackageGroup struct {
title string
packages []statusPackage
}
type statusViewLogo struct {
product serverpkg.Product
ecosystem catalog.Ecosystem
version versionpkg.Version
large []string
small []string
}
func (l statusViewLogo) empty() bool {
return len(l.large) == 0 && len(l.small) == 0
}
func (l statusViewLogo) largeWidth() int {
return statusViewBlockWidth(l.large)
}
func (l statusViewLogo) smallWidth() int {
return statusViewBlockWidth(l.small)
}
func statusViewBlockWidth(lines []string) int {
if len(lines) == 0 {
return 0
}
return lipgloss.Width(lines[0])
}
func resolveStatusViewLogo(
document statusDocument,
theme style.Theme,
) statusViewLogo {
if document.Server != nil {
product, ok := serverpkg.ParseProduct(document.Server.Product.Name)
if ok {
logo := statusViewLogo{
product: product,
ecosystem: statusViewLogoEcosystem(document.Server),
version: versionpkg.Version(document.Server.Minecraft),
}
if terminal.GetLogo(
logo.product,
logo.ecosystem,
logo.version,
terminal.LogoSmallPlain,
) == "" {
fallback := catalog.Ecosystem(
strings.ToLower(document.Server.Product.Ecosystem),
)
if fallback.Valid() &&
fallback != catalog.EcosystemUnspecified &&
terminal.GetLogo(
logo.product,
fallback,
logo.version,
terminal.LogoSmallPlain,
) != "" {
logo.ecosystem = fallback
}
}
return statusViewLogoAssets(logo, theme)
}
}
if document.MCDR != nil {
return statusViewLogoAssets(statusViewLogo{
product: serverpkg.ProductMCDReforged,
ecosystem: catalog.EcosystemMCDR,
version: versionpkg.Unknown,
}, theme)
}
return statusViewLogo{}
}
// A full modding ecosystem selects the logo before the product's own ecosystem.
func statusViewLogoEcosystem(server *statusServer) catalog.Ecosystem {
var fallback catalog.Ecosystem
for _, offer := range server.CompatibleEcosystems {
ecosystem := catalog.Ecosystem(strings.ToLower(offer.Ecosystem))
if !ecosystem.Valid() || ecosystem == catalog.EcosystemUnspecified {
continue
}
if offer.Compatibility == string(workspace.CompatFull) &&
ecosystem.IsModding() {
return ecosystem
}
if fallback == catalog.EcosystemUnspecified {
fallback = ecosystem
}
}
productEcosystem := catalog.Ecosystem(
strings.ToLower(server.Product.Ecosystem),
)
if productEcosystem.Valid() &&
productEcosystem != catalog.EcosystemUnspecified {
return productEcosystem
}
return fallback
}
func statusViewLogoAssets(logo statusViewLogo, theme style.Theme) statusViewLogo {
if terminal.GetLogo(
logo.product,
logo.ecosystem,
logo.version,
terminal.LogoSmallPlain,
) == "" {
return statusViewLogo{}
}
smallVariant := terminal.LogoSmallPlain
largeVariant := terminal.LogoLargePlain
if theme.Colors() {
if terminal.GetLogo(
logo.product,
logo.ecosystem,
logo.version,
terminal.LogoSmallColored,
) != "" {
smallVariant = terminal.LogoSmallColored
}
if terminal.GetLogo(
logo.product,
logo.ecosystem,
logo.version,
terminal.LogoLargeColored,
) != "" {
largeVariant = terminal.LogoLargeColored
}
}
logo.small = statusViewLogoLines(terminal.GetLogo(
logo.product,
logo.ecosystem,
logo.version,
smallVariant,
))
logo.large = statusViewLogoLines(terminal.GetLogo(
logo.product,
logo.ecosystem,
logo.version,
largeVariant,
))
if len(logo.large) == 0 {
logo.large = logo.small
}
return logo
}
func statusViewLogoLines(raw string) []string {
raw = strings.ReplaceAll(raw, "\r", "")
lines := strings.Split(raw, "\n")
for len(lines) > 0 && strings.TrimSpace(ansi.Strip(lines[0])) == "" {
lines = lines[1:]
}
for len(lines) > 0 &&
strings.TrimSpace(ansi.Strip(lines[len(lines)-1])) == "" {
lines = lines[:len(lines)-1]
}
if len(lines) == 0 {
return nil
}
maxWidth := 0
for _, line := range lines {
maxWidth = max(maxWidth, lipgloss.Width(line))
}
for i, line := range lines {
if width := lipgloss.Width(line); width < maxWidth {
lines[i] = line + strings.Repeat(" ", maxWidth-width)
}
}
return lines
}
func statusViewVariantLines(logo statusViewLogo, large bool) []string {
if large {
if len(logo.large) > 0 {
return logo.large
}
return logo.small
}
if len(logo.small) > 0 {
return logo.small
}
return logo.large
}
func statusViewCompose(
info string,
logo statusViewLogo,
layout terminal.StatusLayoutParams,
logoMode terminal.StatusLogoMode,
) string {
infoLines := statusViewLines(info)
switch layout.Mode {
case terminal.LayoutLargeLogoSideBySide:
return statusViewJoin(statusViewSideBySide(
infoLines,
statusViewVariantLines(logo, true),
layout,
))
case terminal.LayoutSmallLogoSideBySide:
return statusViewJoin(statusViewSideBySide(
infoLines,
statusViewVariantLines(logo, false),
layout,
))
case terminal.LayoutVertical:
logoLines := statusViewVariantLines(
logo,
logoMode != terminal.StatusLogoSmall,
)
lines := make([]string, 0, len(logoLines)+len(infoLines)+1)
for _, line := range logoLines {
lines = append(lines, statusViewClip(line, layout.InfoWidth))
}
lines = append(lines, "")
lines = append(lines, infoLines...)
return statusViewJoin(lines)
case terminal.LayoutClipped:
return statusViewJoin(statusViewClipLines(infoLines, layout.InfoWidth))
default:
return statusViewJoin(infoLines)
}
}
func statusViewSideBySide(
infoLines []string,
logoLines []string,
layout terminal.StatusLayoutParams,
) []string {
logoWidth := layout.LogoWidth
if logoWidth <= 0 {
logoWidth = statusViewBlockWidth(logoLines)
}
gapWidth := max(layout.GapWidth, 0)
gap := strings.Repeat(" ", gapWidth)
rows := max(len(infoLines), len(logoLines))
lines := make([]string, 0, rows)
for i := range rows {
left := ""
if i < len(logoLines) {
left = statusViewClip(logoLines[i], logoWidth)
}
right := ""
if i < len(infoLines) {
right = infoLines[i]
}
var line string
switch {
case right == "":
line = left
case left == "":
line = strings.Repeat(" ", logoWidth+gapWidth) + right
default:
padding := max(logoWidth-ansi.StringWidth(left), 0)
line = left +
strings.Repeat(" ", padding) +
gap +
right
}
lines = append(lines, statusViewTrim(line))
}
return lines
}
func statusViewLines(value string) []string {
value = strings.TrimRight(value, "\n")
if value == "" {
return nil
}
lines := strings.Split(value, "\n")
for i, line := range lines {
lines[i] = statusViewTrim(line)
}
return lines
}
func statusViewJoin(lines []string) string {
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
for i, line := range lines {
lines[i] = statusViewTrim(line)
}
return strings.Join(lines, "\n")
}
func statusViewTrim(line string) string {
return strings.TrimRight(line, " \t\r")
}
func statusViewClip(line string, width int) string {
if width <= 0 {
return ""
}
if ansi.StringWidth(line) <= width {
return line
}
return ansi.Truncate(line, width, "")
}
func statusViewClipLines(lines []string, width int) []string {
clipped := make([]string, len(lines))
for i, line := range lines {
clipped[i] = statusViewClip(line, width)
}
return clipped
}
func statusViewFacts(
document statusDocument,
packages []statusViewPackageGroup,
long bool,
width int,
theme style.Theme,
) [][]terminal.Fact {
runtime := []terminal.Fact{}
serverRow := -1
if server := document.Server; server != nil {
if server.Minecraft != "" {
runtime = append(runtime, terminal.Fact{Key: "Minecraft", Value: server.Minecraft})
}
serverRow = len(runtime)
runtime = append(
runtime,
terminal.Fact{Key: "Server"},
terminal.Fact{
Key: "Activity",
Value: statusViewActivity(document.Source, server.Activity, theme),
},
)
} else {
runtime = append(runtime, terminal.Fact{
Key: "Server",
Value: theme.Muted("no server found"),
})
}
if document.MCDR != nil {
runtime = append(runtime, statusViewMCDRFact(document.MCDR))
}
contents := []terminal.Fact{}
if document.Server != nil && len(document.Server.CompatibleEcosystems) > 0 {
contents = append(contents, terminal.Fact{
Key: "Ecosystems",
Value: statusViewCompatibility(
document.Server.CompatibleEcosystems,
theme,
),
})
}
packageStart := len(contents)
if !long {
for _, group := range packages {
contents = append(contents, terminal.Fact{Key: statusViewPackageGroupKey(group)})
}
}
groups := [][]terminal.Fact{runtime, contents}
valueWidth := statusViewValueWidth(groups, width)
if serverRow >= 0 {
runtime[serverRow].Value = statusViewServer(
document.Root,
document.Server,
valueWidth,
theme,
)
}
if !long {
for i, group := range packages {
contents[packageStart+i].Value = statusViewPackageGrid(
group.packages,
valueWidth,
theme,
)
}
}
return groups
}
func statusViewValueWidth(groups [][]terminal.Fact, width int) int {
keyWidth := 0
for _, group := range groups {
for _, fact := range group {
keyWidth = max(keyWidth, ansi.StringWidth(fact.Key))
}
}
keyWidth = min(keyWidth, max(1, width/3))
return max(1, width-keyWidth-2)
}
func statusViewServer(
root string,
server *statusServer,
width int,
theme style.Theme,
) string {
product := terminal.TruncateMiddle(statusViewProduct(server.Product), width)
value := product
if server.EntryPoint != "" {
filename := statusViewLauncher(root, server.EntryPoint)
remaining := width - ansi.StringWidth(product) - 3
if remaining >= min(8, ansi.StringWidth(filename)) {
value += " " + theme.Muted(
terminal.TruncateMiddle(filename, remaining),
)
} else {
value += "\n" + theme.Muted(
terminal.TruncateMiddle(filename, width),
)
}
}
if components := statusViewComponents(server); components != "" {
value += "\n" + theme.Muted(components)
}
return value
}
// Loader installs expose runtime arguments as their entry point. Operators
// start the server through the generated launch script, so show that instead.
func statusViewLauncher(root, entryPoint string) string {
filename := filepath.Base(terminal.DisplayPath(root, entryPoint))
if filename != "unix_args.txt" && filename != "win_args.txt" {
return filename
}
for _, candidate := range []string{"run.sh", "run.bat", "start.sh", "start.bat"} {
if _, err := os.Stat(filepath.Join(root, candidate)); err == nil {
return candidate
}
}
return filename
}
func statusViewProduct(product statusProduct) string {
name := strings.TrimSpace(product.Name)
if parsed, ok := serverpkg.ParseProduct(name); ok {
name = parsed.Title()
} else if name != "" {
name = style.Capitalize(name)
}
parts := make([]string, 0, 3)
if name != "" {
parts = append(parts, name)
}
if version := strings.TrimSpace(product.Version); version != "" {
parts = append(parts, version)
}
if len(parts) == 0 {
return "unknown"
}
return strings.Join(parts, " ")
}
func statusViewActivity(
source string,
activity string,
theme style.Theme,
) string {
activity = strings.ToLower(strings.TrimSpace(activity))
if strings.EqualFold(strings.TrimSpace(source), statusSourceLock) {
return theme.Muted("not observed")
}
switch activity {
case statusActivityRunning:
return theme.Success("● running")
case statusActivityStopped:
return theme.Muted("○ stopped")
case "", statusActivityUnknown:
return theme.Muted("unknown")
default:
return "○ " + activity
}
}
func statusViewProvider(provider string) string {
provider = strings.TrimSpace(provider)
if strings.EqualFold(provider, "any") || strings.EqualFold(provider, "unknown") {
return ""
}
return provider
}
func statusViewComponents(server *statusServer) string {
lines := make([]string, 0, len(server.Components))
for _, component := range server.Components {
minecraft := component.Ecosystem == "minecraft" && component.Version == server.Minecraft
primary := component.Ecosystem == server.Product.Ecosystem && component.Version == server.Product.Version
if minecraft || primary {
continue
}
lines = append(lines, statusViewProduct(statusProduct{
Name: component.Name,
Version: component.Version,
}))
}
return strings.Join(lines, ", ")
}
func statusViewCompatibility(
ecosystems []statusEcosystem,
theme style.Theme,
) string {
labels := make([]string, 0, len(ecosystems))
for _, offer := range ecosystems {
title := strings.TrimSpace(offer.Ecosystem)
if title == "" {
continue
}
if parsed := catalog.Ecosystem(strings.ToLower(title)); parsed.Valid() {
title = parsed.Title()
}
if compatibility := strings.TrimSpace(offer.Compatibility); compatibility != "" {
title += " " + theme.Muted("("+compatibility+")")
}
labels = append(labels, title)
}
return strings.Join(labels, ", ")
}
func statusViewPackageGroups(packages []statusPackage) []statusViewPackageGroup {
mods := make([]statusPackage, 0, len(packages))
plugins := make([]statusPackage, 0, len(packages))
mcdr := make([]statusPackage, 0, len(packages))
for _, pkg := range packages {
ecosystem := catalog.Ecosystem(strings.ToLower(pkg.Ecosystem))
switch {
case strings.EqualFold(
pkg.Environment,
string(serverpkg.EnvironmentMCDR),
) || ecosystem == catalog.EcosystemMCDR:
mcdr = append(mcdr, pkg)
case ecosystem.IsModding():
mods = append(mods, pkg)
default:
plugins = append(plugins, pkg)
}
}
groups := make([]statusViewPackageGroup, 0, 3)
for _, group := range []statusViewPackageGroup{
{title: "Mods", packages: mods},
{title: "Plugins", packages: plugins},
{title: "MCDR plugins", packages: mcdr},
} {
if len(group.packages) > 0 {
slices.SortFunc(group.packages, func(a, b statusPackage) int {
return cmp.Or(cmp.Compare(a.Name, b.Name), cmp.Compare(a.Path, b.Path))
})
groups = append(groups, group)
}
}
return groups
}
func statusViewPackageGroupKey(group statusViewPackageGroup) string {
return fmt.Sprintf("%s (%d)", group.title, len(group.packages))
}
func statusViewPackageTable(
root string,
packages []statusPackage,
width int,
theme style.Theme,
) string {
width = max(1, width-2)
nameWidth, versionWidth := 1, 1
for _, pkg := range packages {
nameWidth = max(nameWidth, ansi.StringWidth(statusViewPackageName(pkg)))
versionWidth = max(versionWidth, ansi.StringWidth(statusViewPackageVersion(pkg)))
}
nameWidth = min(nameWidth, max(1, width*2/5))
versionWidth = min(versionWidth, max(7, width/6))
gap := statusViewColumnGap
if width < 12 {
gap = 1
}
pathWidth := max(1, width-nameWidth-versionWidth-2*gap)
var table strings.Builder
for i, pkg := range packages {
if i > 0 {
table.WriteByte('\n')
}
table.WriteString(" ")
name := terminal.TruncateMiddle(statusViewPackageName(pkg), nameWidth)
if width < 5 {
table.WriteString(terminal.TruncateMiddle(name, width))
continue
}
version := terminal.TruncateMiddle(statusViewPackageVersion(pkg), versionWidth)
path := terminal.DisplayPath(root, pkg.Path)
table.WriteString(name)
table.WriteString(strings.Repeat(" ", nameWidth-ansi.StringWidth(name)+gap))
table.WriteString(version)
table.WriteString(strings.Repeat(" ", versionWidth-ansi.StringWidth(version)+gap))
table.WriteString(theme.Muted(terminal.TruncateMiddle(path, pathWidth)))
}
return table.String()
}
// statusViewPackageVersion names a missing version instead of leaving the
// column empty, so the path column keeps its alignment and meaning.
func statusViewPackageVersion(pkg statusPackage) string {
if version := strings.TrimSpace(pkg.Version); version != "" {
return version
}
return "unknown"
}
func statusViewPackageGrid(
packages []statusPackage,
width int,
theme style.Theme,
) string {
labels := statusViewPackageLabels(packages)
if len(labels) == 0 {
return ""
}
cellBudget := max(18, (width-2*statusViewColumnGap)/3)
longest := 0
for i, label := range labels {
labels[i] = terminal.TruncateMiddle(label, max(1, min(width, cellBudget)))
longest = max(longest, ansi.StringWidth(labels[i]))
}
columnWidth := longest + statusViewColumnGap
columns := (width + statusViewColumnGap) / max(columnWidth, 1)
columns = max(1, min(columns, len(labels)))
visible := min(len(labels), columns*statusViewPackageGridLines)
lines := make([]string, 0, statusViewPackageGridLines+1)
for start := 0; start < visible; start += columns {
end := min(start+columns, visible)
var row strings.Builder
for i := start; i < end; i++ {
row.WriteString(labels[i])
if i < end-1 {
padding := columnWidth - ansi.StringWidth(labels[i])
row.WriteString(strings.Repeat(" ", max(padding, 1)))
}
}
lines = append(lines, row.String())
}
if hidden := len(labels) - visible; hidden > 0 {
lines = append(lines, terminal.FoldHint(hidden, theme))
}
return strings.Join(lines, "\n")
}
func statusViewPackageLabels(packages []statusPackage) []string {
counts := make(map[string]int, len(packages))
for _, pkg := range packages {
counts[statusViewPackageName(pkg)]++
}
labels := make([]string, 0, len(packages))
for _, pkg := range packages {
name := statusViewPackageName(pkg)
label := name
if counts[name] > 1 {
if provider := statusViewProvider(pkg.Provider); provider != "" {
label = provider + ":" + name
} else if ecosystem := strings.TrimSpace(pkg.Ecosystem); ecosystem != "" {
label = ecosystem + "/" + name
}
}
labels = append(labels, label)
}
return labels
}
func statusViewPackageName(pkg statusPackage) string {
if name := strings.TrimSpace(pkg.Name); name != "" {
return name
}
if provider := statusViewProvider(pkg.Provider); provider != "" {
return provider
}
if ecosystem := strings.TrimSpace(pkg.Ecosystem); ecosystem != "" {
return ecosystem
}
return "unknown"
}
func statusViewMCDRFact(mcdr *statusMCDR) terminal.Fact {
value := strings.TrimSpace(mcdr.Version)
if value == "" {
value = "installed"
}
return terminal.Fact{Key: "MCDR", Value: value}
}
package cli
import (
"cmp"
"fmt"
"slices"
"github.com/mclucy/lucy/artifactstore"
"github.com/mclucy/lucy/cas"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/terminal"
"github.com/mclucy/lucy/terminal/style"
"github.com/spf13/cobra"
)
// storeObject is one stored object as the command presents it.
type storeObject struct {
Digest string `json:"digest"`
Size int64 `json:"size"`
}
// storeListJSON is the machine-readable `store list` result: every stored
// object in digest order.
var storeListJSON JSONResult[[]storeObject]
// storeGCJSON is the machine-readable result of one collection.
var storeGCJSON JSONResult[artifactstore.Report]
func NewStoreCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
storeListCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: help.StoreListShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionStoreList(cmd, inv, args)
},
}
storeGCCmd := &cobra.Command{
Use: "gc",
Short: help.StoreGCShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionStoreGC(cmd, inv, deps, args)
},
}
storeCmd := &cobra.Command{
Use: "store",
Short: help.StoreShort,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
MarkHelpOnly(storeCmd)
storeListJSON.Bind(contracts, storeListCmd)
storeGCJSON.Bind(contracts, storeGCCmd)
storeCmd.AddCommand(storeListCmd, storeGCCmd)
return storeCmd
}
func actionStoreList(
cmd *cobra.Command,
inv *Invocation,
_ []string,
) error {
output := NewQueryOutput(cmd, inv)
store, err := artifactstore.Open()
if err != nil {
return err
}
defer store.Close()
objects := make([]cas.Object, 0)
if err := store.Walk(func(object cas.Object) error {
objects = append(objects, object)
return nil
}); err != nil {
return fmt.Errorf("list artifact store: %w", err)
}
slices.SortFunc(objects, func(a, b cas.Object) int {
return cmp.Compare(a.Digest.String(), b.Digest.String())
})
if output.JSON() {
presented := make([]storeObject, 0, len(objects))
for _, object := range objects {
presented = append(presented, storeObject{
Digest: object.Digest.String(),
Size: object.Size,
})
}
return storeListJSON.Write(output, presented)
}
if len(objects) == 0 {
_, err := fmt.Fprintln(output.Human(), "Artifact store is empty")
return err
}
var total int64
for _, object := range objects {
total += object.Size
}
facts := make([]terminal.Fact, 0, len(objects)+1)
facts = append(facts, terminal.Fact{
Key: "Store",
Value: fmt.Sprintf("%d objects, %s", len(objects), style.FormatBytesBinary(total)),
})
for _, object := range objects {
facts = append(facts, terminal.Fact{
Key: object.Digest.String(),
Value: style.FormatBytesBinary(object.Size),
})
}
_, err = fmt.Fprintln(
output.Human(),
terminal.RenderFacts(facts, inv.Stdout.Width, inv.Theme),
)
return err
}
func actionStoreGC(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) error {
output := NewOperationOutput(cmd, inv)
registry, err := deps.ServerRegistry()
if err != nil {
return err
}
store, err := artifactstore.Open()
if err != nil {
return err
}
defer store.Close()
report, err := store.GC(registry)
if err != nil {
return err
}
if output.JSON() {
return storeGCJSON.Write(output, report)
}
if report.Removed == 0 {
_, err := fmt.Fprintln(output.Human(), "Artifact store has no unreferenced objects")
return err
}
_, err = fmt.Fprintf(
output.Human(),
"removed %d objects, freed up %s of space\n",
report.Removed,
style.FormatBytesBinary(report.Bytes),
)
return err
}
package cli
import (
"errors"
"strings"
"github.com/mclucy/lucy/javaruntime"
"github.com/mclucy/lucy/telemetry"
"github.com/mclucy/lucy/workspace"
"github.com/mclucy/lucy/workspace/discovery"
)
type telemetryWorkspace struct {
Product string `json:"product,omitempty"`
ProductVersion string `json:"product_version,omitempty"`
MinecraftVersion string `json:"minecraft_version,omitempty"`
Packages int `json:"packages"`
}
func recordWorkspaceTelemetryFailure(inv *Invocation) {
if inv.TelemetryEnabled {
inv.TelemetryContexts = append(inv.TelemetryContexts, telemetry.Context{Kind: "workspace", Status: telemetry.ContextStatus("failed")})
}
}
func recordWorkspaceTelemetry(inv *Invocation, observed *workspace.Workspace, document statusDocument) {
if !inv.TelemetryEnabled {
return
}
context := telemetry.Context{Kind: "workspace", Status: telemetry.ContextStatus("present")}
if observed == nil || document.Server == nil {
context.Status = telemetry.ContextStatus("absent")
if observed != nil {
if selection, ok := errors.AsType[*discovery.SelectionError](observed.SelectionError()); ok && selection.State != discovery.SelectionNone {
context.Status = telemetry.ContextStatus("failed")
}
}
} else {
context.Data = telemetryWorkspace{
Product: observed.Runtime().Product().String(),
ProductVersion: telemetryVersion(document.Server.Product.Version),
MinecraftVersion: telemetryVersion(document.Server.Minecraft),
Packages: len(document.Packages),
}
}
inv.TelemetryContexts = append(inv.TelemetryContexts, context)
}
type telemetryJava struct {
Major int `json:"major"`
Version string `json:"version,omitempty"`
Distribution string `json:"distribution,omitempty"`
}
func recordJavaTelemetryFailure(inv *Invocation) {
if inv.TelemetryEnabled {
inv.TelemetryContexts = append(inv.TelemetryContexts, telemetry.Context{Kind: "java", Status: telemetry.ContextStatus("failed")})
}
}
func recordJavaTelemetry(inv *Invocation, selection *javaruntime.Check) {
if !inv.TelemetryEnabled || selection == nil {
return
}
context := telemetry.Context{Kind: "java", Status: telemetry.ContextStatus("absent")}
if selection.Runtime != nil {
context.Status = telemetry.ContextStatus("present")
context.Data = telemetryJava{
Major: selection.Runtime.Major,
Version: telemetryVersion(selection.Runtime.Version),
Distribution: string(selection.Runtime.Distribution),
}
}
inv.TelemetryContexts = append(inv.TelemetryContexts, context)
}
func telemetryVersion(value string) string {
if len(value) > 64 || !strings.ContainsAny(value, "0123456789") ||
strings.Trim(value, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.+-_") != "" {
return ""
}
return value
}
package cli
import (
"fmt"
"io"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/spf13/cobra"
)
var treeJSON JSONResult[treeOutput]
func NewTreeCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
treeCmd := &cobra.Command{
Use: "tree",
Short: help.TreeShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionTree(cmd, inv, deps, args)
},
}
treeJSON.Bind(contracts, treeCmd)
treeCmd.Flags().Bool(
"live",
false,
"Observe the live workspace instead of reading the lock",
)
treeCmd.Flags().Int(
"depth",
0,
"Limit dependency tree depth (0 = unlimited)",
)
return treeCmd
}
func actionTree(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
_ []string,
) error {
ctx, stop := CommandContext(cmd)
defer stop()
forceLive, _ := cmd.Flags().GetBool("live")
output := NewQueryOutput(cmd, inv)
graph, origin, _, err := LoadDependencyData(
ctx,
inv.Directory,
forceLive,
deps.Router.ResolveArtifactByHash,
output,
)
if err != nil {
return err
}
if output.JSON() {
return treeJSON.Write(output, treeDocument(graph, origin, output.Warnings()))
}
maxDepth, _ := cmd.Flags().GetInt("depth")
human := output.Human()
fmt.Fprintf(human, "Using data from: %s\n\n", origin.String())
roots := graph.GetRoots()
for i, root := range roots {
printTree(human, root, 0, i == len(roots)-1, "", make(map[string]bool), maxDepth)
}
fmt.Fprintf(human, "\n(from %s)\n", origin.String())
return nil
}
// nodeLabel renders one graph node. Node identity is always an explicit
// artifact coordinate or a native module ID; nothing is re-derived by
// parsing the label.
func nodeLabel(node *GraphNode) string {
label := fmt.Sprintf("%s@%s", node.ID, node.Version)
if node.Provider != catalog.ProviderUnspecified {
label += fmt.Sprintf(" (%s)", node.Provider)
}
if node.Module {
label += " [module]"
}
return label
}
func printTree(
w io.Writer,
node *GraphNode,
depth int,
isLast bool,
prefix string,
visited map[string]bool,
maxDepth int,
) {
branch := "├── "
if isLast {
branch = "└── "
}
label := nodeLabel(node)
key := node.key()
if visited[key] {
fmt.Fprintf(w, "%s%s%s [shown above]\n", prefix, branch, label)
return
}
fmt.Fprintf(w, "%s%s%s\n", prefix, branch, label)
if maxDepth > 0 && depth >= maxDepth {
return
}
visited[key] = true
childPrefix := prefix + "│ "
if isLast {
childPrefix = prefix + " "
}
for i, child := range node.Children {
printTree(w, child, depth+1, i == len(node.Children)-1, childPrefix, visited, maxDepth)
}
delete(visited, key)
}
type treeNode struct {
ID string `json:"id"`
Version string `json:"version"`
Provider string `json:"provider,omitempty"`
Project string `json:"project,omitempty"`
Artifact string `json:"artifact,omitempty"`
Filename string `json:"filename,omitempty"`
Environment string `json:"environment"`
Ecosystem string `json:"ecosystem"`
Module bool `json:"module,omitzero"`
Root bool `json:"root,omitzero"`
Children []*treeNode `json:"children,omitempty"`
}
// treeOutput is the complete machine-readable `tree` result.
type treeOutput struct {
Origin string `json:"origin"`
Roots []*treeNode `json:"roots"`
Warnings []string `json:"warnings,omitempty"`
}
func treeDocument(
graph *DependencyGraph,
origin GraphOrigin,
warnings []string,
) treeOutput {
visited := make(map[string]bool)
roots := graph.GetRoots()
jsonRoots := make([]*treeNode, 0, len(roots))
for _, root := range roots {
jsonRoots = append(jsonRoots, buildJSONNode(root, visited))
}
return treeOutput{
Origin: origin.String(),
Roots: jsonRoots,
Warnings: warnings,
}
}
func buildJSONNode(node *GraphNode, visited map[string]bool) *treeNode {
provider := ""
if node.Provider != catalog.ProviderUnspecified {
provider = node.Provider.String()
}
t := &treeNode{
ID: node.ID,
Version: node.Version,
Provider: provider,
Project: node.ProjectID,
Artifact: node.Artifact,
Filename: node.Filename,
Environment: string(node.Environment),
Ecosystem: node.Ecosystem.String(),
Module: node.Module,
Root: node.Root,
}
key := node.key()
if visited[key] {
return t
}
visited[key] = true
for _, child := range node.Children {
t.Children = append(t.Children, buildJSONNode(child, visited))
}
delete(visited, key)
return t
}
package cli
import (
"context"
"errors"
"fmt"
"strings"
"charm.land/huh/v2"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/operation"
"github.com/spf13/cobra"
)
const (
flagUpgradeInteractiveName = "interactive"
flagUpgradeDryRunName = "dry-run"
flagUpgradePolicyName = "policy"
)
var upgradeFrontierJSON JSONResult[upgradeFrontierDocument]
func NewUpgradeCommand(
contracts *JSONContracts,
inv *Invocation,
deps *Dependencies,
) *cobra.Command {
upgradeCmd := &cobra.Command{
Use: "upgrade [packages...]",
Short: help.UpgradeShort,
Aliases: []string{"up", "update"},
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionUpgrade(cmd, inv, deps, args)
},
}
upgradeFrontierJSON.Bind(contracts, upgradeCmd)
installJSON.Bind(contracts, upgradeCmd)
upgradeCmd.Flags().Bool(
flagUpgradeInteractiveName,
false,
"Select manifest roots to upgrade",
)
upgradeCmd.Flags().Bool(
flagUpgradeDryRunName,
false,
"Resolve and show the plan without changing files",
)
upgradeCmd.Flags().String(
flagUpgradePolicyName,
"more",
"Upgrade policy: more, later, or options",
)
return upgradeCmd
}
func actionUpgrade(
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
args []string,
) error {
interactive, _ := cmd.Flags().GetBool(flagUpgradeInteractiveName)
dryRun, _ := cmd.Flags().GetBool(flagUpgradeDryRunName)
policy, _ := cmd.Flags().GetString(flagUpgradePolicyName)
noInteractive, _ := cmd.Flags().GetBool(FlagNoInteractive)
jsonMode := JSONMode(cmd) != JSONNone
switch {
case interactive && len(args) > 0:
return fmt.Errorf("--%s cannot be combined with package arguments", flagUpgradeInteractiveName)
case interactive && noInteractive:
return fmt.Errorf("--%s cannot be combined with --%s", flagUpgradeInteractiveName, FlagNoInteractive)
case policy != "more" && policy != "later" && policy != "options" && !isUpgradeHash(policy):
return fmt.Errorf("unknown upgrade policy %q", policy)
case policy == "options" && noInteractive && !dryRun && !jsonMode:
return fmt.Errorf("--%s options needs a selection; use --%s <hash>", flagUpgradePolicyName, flagUpgradePolicyName)
}
ctx, stop := CommandContext(cmd)
defer stop()
targets := args
if interactive {
selected, err := promptUpgradeRoots(ctx, cmd, inv, inv.Directory)
if err != nil {
return err
}
targets = selected
}
output := newInstallOutput(cmd, inv)
result, err := deps.Operations.PlanUpgrade(
ctx,
inv.Directory,
operation.UpgradeOptions{
Targets: targets,
Trace: output.Trace(),
},
)
if err != nil {
return output.Failure(err)
}
output.Close()
if jsonMode && (dryRun || policy == "options") {
selectedHash := ""
if policy != "options" {
selected, err := operation.SelectUpgradeOption(result, policy)
if err != nil {
return err
}
selectedHash = selected.Hash
}
return upgradeFrontierJSON.Write(output.Output, upgradeFrontierDocument{
Schema: 1,
Kind: "upgrade-frontier",
Policy: policy,
SelectedHash: selectedHash,
BaseManifestFingerprint: result.BaseManifestFingerprint,
BaseLockFingerprint: result.BaseLockFingerprint,
Frontier: upgradeFrontierEntries(result.Options),
Warnings: warningMessages(result.Warnings),
})
}
if policy == "options" {
if dryRun {
output.RenderUpgradeFrontier(result)
printUpgradeWarnings(output, result.Warnings)
return nil
}
index, err := promptUpgradeChoice(ctx, cmd, inv, result.Options)
if err != nil {
return err
}
option := result.Options[index]
output.RenderUpgradePlan(option, "options", false)
printUpgradeWarnings(output, result.Warnings)
_, err = applyUpgradeOption(ctx, cmd, inv, deps, option, "options", result.Warnings)
return err
}
selected, err := operation.SelectUpgradeOption(result, policy)
if err != nil {
return err
}
if dryRun {
output.RenderUpgradePlan(*selected, policy, true)
printUpgradeWarnings(output, result.Warnings)
return nil
}
output.RenderUpgradePlan(*selected, policy, false)
if !selected.Plan.Result.Changed() {
printUpgradeWarnings(output, result.Warnings)
if jsonMode {
document := selected.Plan.Result
document.WarningMessages = append(warningMessages(document.Warnings), warningMessages(result.Warnings)...)
return installJSON.Write(output.Output, document)
}
return nil
}
if !noInteractive && !jsonMode {
if !NewPromptGate(cmd, inv).Available() {
return fmt.Errorf("apply without a prompt with --%s", FlagNoInteractive)
}
confirmed, err := promptUpgradeConfirm(ctx, cmd)
if err != nil {
return err
}
if !confirmed {
return nil
}
}
applied, err := applyUpgradeOption(ctx, cmd, inv, deps, *selected, policy, result.Warnings)
if err != nil {
return err
}
if jsonMode {
return installJSON.Write(output.Output, applied)
}
return nil
}
func applyUpgradeOption(
ctx context.Context,
cmd *cobra.Command,
inv *Invocation,
deps *Dependencies,
option operation.UpgradeOption,
policy string,
warnings []error,
) (*install.Result, error) {
if option.Plan != nil && option.Plan.Result != nil {
option.Plan.Result.Warnings = append(option.Plan.Result.Warnings, warnings...)
}
if JSONMode(cmd) != JSONNone {
output := newInstallOutput(cmd, inv)
defer output.Close()
result, err := deps.Operations.ApplyUpgrade(
ctx,
inv.Directory,
option,
policy,
install.Options{Trace: output.Trace()},
)
if err != nil {
return nil, output.Failure(err)
}
result.WarningMessages = warningMessages(result.Warnings)
return result, nil
}
output := newInstallOutput(cmd, inv)
defer output.Close()
result, err := deps.Operations.ApplyUpgrade(
ctx,
inv.Directory,
option,
policy,
install.Options{Trace: output.Trace()},
)
if err != nil {
return nil, output.Failure(err)
}
if err := output.RenderUpgradeApplied(result, policy, option.Hash); err != nil {
return nil, err
}
return result, nil
}
func promptUpgradeRoots(
ctx context.Context,
cmd *cobra.Command,
inv *Invocation,
root string,
) ([]string, error) {
if !NewPromptGate(cmd, inv).Available() {
return nil, errors.New("interactive selection needs a terminal; name packages instead")
}
refs, err := operation.ListUpgradeRoots(root)
if err != nil {
return nil, err
}
if len(refs) == 0 {
return nil, errors.New("the manifest has no enabled package roots")
}
options := make([]huh.Option[string], 0, len(refs))
for _, ref := range refs {
options = append(options, huh.NewOption(ref, ref))
}
var selected []string
form := huh.NewForm(huh.NewGroup(huh.NewMultiSelect[string]().
Title("Select packages to upgrade").
Options(options...).
Value(&selected))).
WithInput(cmd.InOrStdin()).
WithOutput(cmd.ErrOrStderr())
if err := NormalizePromptError(ctx, form.RunWithContext(ctx)); err != nil {
return nil, err
}
if len(selected) == 0 {
return nil, errors.New("no packages selected")
}
return selected, nil
}
func promptUpgradeChoice(
ctx context.Context,
cmd *cobra.Command,
inv *Invocation,
options []operation.UpgradeOption,
) (int, error) {
if !NewPromptGate(cmd, inv).Available() {
return 0, errors.New("interactive selection needs a terminal; use --policy <hash>")
}
huhOptions := make([]huh.Option[int], 0, len(options))
for i, option := range options {
huhOptions = append(huhOptions, huh.NewOption(
fmt.Sprintf("%d. %s", i+1, upgradeOptionSummary(option)),
i,
))
}
selected := 0
form := huh.NewForm(huh.NewGroup(huh.NewSelect[int]().
Title("Select an upgrade plan").
Options(huhOptions...).
Value(&selected))).
WithInput(cmd.InOrStdin()).
WithOutput(cmd.ErrOrStderr())
if err := NormalizePromptError(ctx, form.RunWithContext(ctx)); err != nil {
return 0, err
}
return selected, nil
}
func promptUpgradeConfirm(ctx context.Context, cmd *cobra.Command) (bool, error) {
confirmed := false
form := huh.NewForm(huh.NewGroup(huh.NewConfirm().
Title("Apply the planned upgrade?").
Value(&confirmed))).
WithInput(cmd.InOrStdin()).
WithOutput(cmd.ErrOrStderr())
if err := NormalizePromptError(ctx, form.RunWithContext(ctx)); err != nil {
return false, err
}
return confirmed, nil
}
func isUpgradeHash(value string) bool {
return strings.HasPrefix(value, "sha256:")
}
package cli
import (
"fmt"
"strings"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/operation"
)
type upgradeFrontierDocument struct {
Schema int `json:"schema"`
Kind string `json:"kind"`
Policy string `json:"policy,omitempty"`
SelectedHash string `json:"selected_hash,omitempty"`
BaseManifestFingerprint string `json:"base_manifest_fingerprint"`
BaseLockFingerprint string `json:"base_lock_fingerprint"`
Frontier []upgradeFrontierEntry `json:"frontier"`
Warnings []string `json:"warnings,omitempty"`
}
type upgradeFrontierEntry struct {
Hash string `json:"hash"`
Metrics operation.UpgradeMetrics `json:"metrics"`
Result *install.Result `json:"result"`
}
func upgradeFrontierEntries(options []operation.UpgradeOption) []upgradeFrontierEntry {
entries := make([]upgradeFrontierEntry, 0, len(options))
for _, option := range options {
if option.Plan != nil && option.Plan.Result != nil {
option.Plan.Result.PlanHash = option.Hash
}
entries = append(entries, upgradeFrontierEntry{
Hash: option.Hash,
Metrics: option.Metrics,
Result: option.Plan.Result,
})
}
return entries
}
func (o *installOutput) RenderUpgradeFrontier(result *operation.UpgradeResult) {
fmt.Fprintln(o.Human(), "Upgrade options:")
for i, option := range result.Options {
fmt.Fprintf(o.Human(), " %d. %s\n", i+1, upgradeOptionSummary(option))
}
}
func (o *installOutput) RenderUpgradePlan(
option operation.UpgradeOption,
policy string,
dryRun bool,
) {
result := option.Plan.Result
if result == nil {
return
}
if !result.Changed() {
fmt.Fprintln(o.Human(), o.inv.Theme.Success("✓")+" Already up to date")
return
}
fmt.Fprintln(o.Human(), "Plan:")
for _, line := range packageChangeLines(result.Packages, o.inv.Theme) {
fmt.Fprintln(o.Human(), line)
}
for _, change := range result.ManifestChanges {
fmt.Fprintf(
o.Human(),
" %s manifest %s %s → %s\n",
o.inv.Theme.Warning("~"),
change.Reference,
change.PreviousSelector,
change.Selector,
)
}
fmt.Fprintln(o.Human(), o.inv.Theme.Muted(fmt.Sprintf(
"%d roots upgraded, %d version bumps",
option.Metrics.UpgradedRoots,
option.Metrics.VersionBumps,
)))
fmt.Fprintln(o.Human(), o.inv.Theme.Muted("Policy: "+policy))
fmt.Fprintln(o.Human(), o.inv.Theme.Muted("Plan: "+option.Hash))
if dryRun {
fmt.Fprintln(o.Human(), o.inv.Theme.Muted("No files were changed."))
}
}
func (o *installOutput) RenderUpgradeApplied(
result *install.Result,
policy string,
hash string,
) error {
if result == nil {
return nil
}
result.Warnings = append(result.Warnings, javaWarnings(result.Java)...)
result.WarningMessages = warningMessages(result.Warnings)
if o.JSON() {
return installJSON.Write(o.Output, result)
}
if result.MCDR != nil {
fmt.Fprintf(
o.Human(),
" %s mcdr python inputs %d → %d\n",
o.inv.Theme.Warning("~"),
result.MCDR.PreviousInputs,
result.MCDR.Inputs,
)
}
fmt.Fprintln(o.Human(), o.inv.Theme.Success("✓")+" Upgrade applied")
if changed := result.Files.Created + result.Files.Replaced + result.Files.Removed; changed > 0 {
fmt.Fprintln(o.Human(), o.inv.Theme.Muted(" "+fileCountLabel(changed)+" changed"))
}
fmt.Fprintln(o.Human(), o.inv.Theme.Muted(" Policy: "+policy))
fmt.Fprintln(o.Human(), o.inv.Theme.Muted(" Plan: "+hash))
if trailer := publicationLine(result, o.inv.Theme); trailer != "" {
fmt.Fprintln(o.Human(), trailer)
}
o.Close()
for _, warning := range result.Warnings {
o.Warn(warning)
}
return nil
}
func printUpgradeWarnings(output *installOutput, warnings []error) {
for _, warning := range warnings {
output.Warn(warning)
}
}
func upgradeOptionSummary(option operation.UpgradeOption) string {
metrics := fmt.Sprintf(
"%d roots upgraded, %d version bumps",
option.Metrics.UpgradedRoots,
option.Metrics.VersionBumps,
)
result := option.Plan.Result
if result == nil {
return metrics
}
changes := make([]string, 0, 4)
for _, change := range result.Packages {
name := change.Name
if change.Previous != "" {
changes = append(changes, fmt.Sprintf("%s %s→%s", name, change.Previous, change.Version))
} else {
changes = append(changes, fmt.Sprintf("%s %s", name, change.Version))
}
if len(changes) == 3 {
break
}
}
if len(result.ManifestChanges) > 0 {
changes = append(changes, fmt.Sprintf("%d manifest repins", len(result.ManifestChanges)))
}
if len(changes) == 0 {
changes = append(changes, "no package changes")
}
return strings.Join(changes, ", ") + " (" + metrics + ")"
}
package compile
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/internal/buildrepo"
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/internal/toolchain"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/report"
"github.com/spf13/cobra"
)
type options struct {
output string
ecosystem string
project string
buildDir string
branch string
tag string
}
func NewCommand(inv *cli.Invocation) *cobra.Command {
var opts options
cmd := &cobra.Command{
Use: "compile <url|owner/repo>",
Short: help.CompileShort,
Long: help.CompileLong,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if cli.JSONMode(cmd) != cli.JSONNone {
return &report.Detail{
Code: "usage.unsupported_output",
Title: "compile does not support JSON output",
Hint: "Run without --json or --json-compact.",
}
}
ctx, stop := cli.CommandContext(cmd)
defer stop()
return run(
ctx,
args[0],
&opts,
inv,
cli.NewPromptGate(cmd, inv),
cmd.OutOrStdout(),
cmd.ErrOrStderr(),
)
},
}
cmd.Flags().StringVarP(&opts.output, "output", "o", "", "Write the compiled mod JAR to PATH, or to a directory under the project's artifact name")
cmd.Flags().StringVar(&opts.ecosystem, cli.FlagEcosystem, "", "Select fabric, forge, or neoforge")
cmd.Flags().StringVar(&opts.project, "project", "", "Select a Gradle project path, such as :fabric")
cmd.Flags().StringVar(&opts.buildDir, "build-dir", "", "Select a Gradle build directory relative to the checkout")
cmd.Flags().StringVar(&opts.branch, "branch", "", "Checkout Git branch BRANCH before compiling")
cmd.Flags().StringVar(&opts.tag, "tag", "", "Checkout Git tag TAG before compiling")
return cmd
}
// run compiles source and writes the resulting JAR. Progress is narrated on
// stderr; stdout carries only the artifact path so the command stays pipeable.
func run(
ctx context.Context,
source string,
opts *options,
inv *cli.Invocation,
gate cli.PromptGate,
stdout, stderr io.Writer,
) (err error) {
reporter := report.New(stderr, inv.Theme)
logger := inv.Logger
remote, err := parseRemote(source)
if err != nil {
return err
}
destination := parseOutputDestination(opts.output)
if opts.ecosystem != "" && !supportedEcosystem(catalog.Ecosystem(opts.ecosystem)) {
return errors.New("--ecosystem must be fabric, forge, or neoforge")
}
branch := strings.TrimSpace(opts.branch)
tag := strings.TrimSpace(opts.tag)
if branch != "" && tag != "" {
return errors.New("--branch and --tag are mutually exclusive")
}
if strings.HasPrefix(branch, "-") || strings.HasPrefix(tag, "-") {
return errors.New("branch and tag names must not start with '-'")
}
output := ""
if destination.file != "" {
if output, err = destination.resolve(inv.Directory, ""); err != nil {
return err
}
if err := ensureAbsent(output); err != nil {
return err
}
}
temporary, err := os.MkdirTemp("", "lucy-compile-*")
if err != nil {
return fmt.Errorf("create temporary checkout: %w", err)
}
defer func() {
if cleanupErr := os.RemoveAll(temporary); cleanupErr != nil {
err = errors.Join(err, fmt.Errorf("remove temporary checkout: %w", cleanupErr))
}
}()
checkout := filepath.Join(temporary, "repository")
ref := refSuffix(branch, tag)
logger.Info("compilation checkout started", "branch", branch, "tag", tag)
writeProgress(stderr, "Cloning "+source+ref)
if err := clone(
ctx,
remote,
checkout,
branch,
tag,
newProcessLog("git", logger, reporter),
); err != nil {
return err
}
logger.Info("compilation checkout completed", "directory", checkout)
writeProgress(stderr, "Cloned "+source+ref)
logger.Info("compilation repository inspection started", "directory", checkout)
layout, err := buildrepo.Probe(ctx, checkout)
if err != nil {
return fmt.Errorf("probe repository: %w", err)
}
reportFindings(layout.Root, layout.Findings, logger, reporter)
build, err := selectBuild(layout, opts.buildDir)
if err != nil {
return err
}
logger.Debug("Gradle build selected", "directory", build.Dir, "requested", opts.buildDir,
"reason", "requested directory or root/unique Gradle build")
logger.Info("compilation toolchain preparation started", "directory", build.Dir)
writeProgress(stderr, "Preparing the Gradle build")
gradleUserHome := os.Getenv("GRADLE_USER_HOME")
if gradleUserHome == "" {
home, homeErr := os.UserHomeDir()
if homeErr != nil {
return fmt.Errorf("find user home: %w", homeErr)
}
gradleUserHome = filepath.Join(home, ".gradle")
}
javaHome, err := configuredGradleJavaHome(build, gradleUserHome)
if err != nil {
return err
}
build.JavaHome = javaHome
inventory, err := toolchain.Discover(ctx, javaHome)
if err != nil {
return fmt.Errorf("discover local toolchains: %w", err)
}
for _, warning := range toolchainWarnings(build, inventory) {
warnErr := errors.New(warning)
logger.Warn(warnErr)
reporter.Warn(warnErr)
}
runner, err := newGradleRunner(
build,
inventory,
temporary,
stderr,
logger,
reporter,
)
if err != nil {
return err
}
model, err := runner.inspect(ctx)
if err != nil {
return err
}
project, err := selectProject(ctx, model, opts, gate)
if err != nil {
return err
}
logger.Debug("compilation project selected", "project", project.Path, "task", project.BuildTask,
"reason", "project selection and supported mod artifact metadata")
// selectProject runs again on the rebuilt model; pin the pick so the
// second pass keeps the same project instead of listing candidates anew.
opts.project = project.Path
compilerProject, err := runner.inspectCompilers(ctx, project)
if err != nil {
return err
}
if err := checkCompiler(compilerProject, runner.daemon); err != nil {
return err
}
model, err = runner.build(ctx, project.BuildTask)
if err != nil {
return err
}
project, err = selectProject(ctx, model, opts, gate)
if err != nil {
return err
}
artifactPath, err := selectArtifact(project, catalog.Ecosystem(opts.ecosystem))
if err != nil {
return err
}
if output == "" {
if output, err = destination.resolve(inv.Directory, filepath.Base(artifactPath)); err != nil {
return err
}
if err := ensureAbsent(output); err != nil {
return err
}
}
if err := publishArtifact(ctx, artifactPath, output); err != nil {
return err
}
logger.Info("compiled artifact published", "path", output, "project", project.Path)
writeProgress(stderr, "Compiled "+filepath.Base(output))
if _, err := fmt.Fprintln(stdout, output); err != nil {
return fmt.Errorf("report output: %w", err)
}
return nil
}
// writeProgress narrates compilation progress on stderr. Progress text is
// advisory, so a write failure does not stop the build.
func writeProgress(writer io.Writer, line string) {
_, _ = fmt.Fprintln(writer, line)
}
// reportFindings surfaces what the static probe could not settle on its own.
// None of them stops a build, but each marks a decision left to Gradle, and
// silently discarding one would leave the user with a wrong model of what Lucy
// verified before running an untrusted build.
func reportFindings(
root string,
findings []buildrepo.Finding,
logger *log.Logger,
reporter *report.Reporter,
) {
for _, finding := range findings {
location := finding.File
if relative, err := filepath.Rel(root, finding.File); err == nil {
location = filepath.ToSlash(relative)
}
if finding.Line > 0 {
location += ":" + strconv.Itoa(finding.Line)
}
warning := fmt.Errorf("%s: %s", location, finding.Message)
logger.Warn(warning)
reporter.Warn(warning)
}
}
func supportedEcosystem(ecosystem catalog.Ecosystem) bool {
switch ecosystem {
case catalog.EcosystemFabric, catalog.EcosystemForge, catalog.EcosystemNeoForge:
return true
default:
return false
}
}
func selectBuild(layout buildrepo.Layout, requested string) (buildrepo.GradleBuild, error) {
if requested != "" {
if !filepath.IsLocal(requested) {
return buildrepo.GradleBuild{}, errors.New("--build-dir must remain inside the checkout")
}
dir := filepath.Join(layout.Root, requested)
for _, build := range layout.Builds {
if build.Dir == dir {
return build, nil
}
}
return buildrepo.GradleBuild{}, fmt.Errorf("no Gradle build at %q", requested)
}
for _, build := range layout.Builds {
if build.Dir == layout.Root {
return build, nil
}
}
included := make(map[string]bool)
for _, build := range layout.Builds {
for _, dir := range build.IncludedBuilds {
included[dir] = true
}
}
candidates := make([]buildrepo.GradleBuild, 0, len(layout.Builds))
for _, build := range layout.Builds {
base := filepath.Base(build.Dir)
isBuildLogic := base == "buildSrc" || base == "build-logic" || base == "buildlogic"
if !included[build.Dir] && !isBuildLogic {
candidates = append(candidates, build)
}
}
if len(candidates) == 1 {
return candidates[0], nil
}
if len(candidates) == 0 {
return buildrepo.GradleBuild{}, errors.New("repository contains no Gradle build")
}
paths := make([]string, 0, len(candidates))
for _, build := range candidates {
relative, err := filepath.Rel(layout.Root, build.Dir)
if err != nil {
return buildrepo.GradleBuild{}, fmt.Errorf("describe build directory: %w", err)
}
paths = append(paths, relative)
}
return buildrepo.GradleBuild{}, fmt.Errorf("multiple Gradle builds found; select --build-dir: %s", strings.Join(paths, ", "))
}
package compile
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/catalog"
)
// outputDestination is where the compiled JAR is written: either an explicit
// JAR file or a directory that receives the project's artifact name.
type outputDestination struct {
directory string
file string
}
// parseOutputDestination classifies the output flag. A path ending in `.jar`
// names the JAR to write; every other value, including an unset flag, names the
// directory that receives the project's artifact name.
func parseOutputDestination(value string) outputDestination {
trimmed := strings.TrimSpace(value)
if strings.EqualFold(filepath.Ext(trimmed), ".jar") {
return outputDestination{file: trimmed}
}
if trimmed == "" {
return outputDestination{directory: "."}
}
return outputDestination{directory: filepath.Clean(trimmed)}
}
// resolve returns the absolute destination for the artifact Gradle built as
// artifactName. A directory destination stores the JAR under that name, while
// an explicit destination keeps the file name the user asked for.
func (d outputDestination) resolve(base, artifactName string) (string, error) {
target := d.file
if d.directory != "" {
target = filepath.Join(d.directory, artifactName)
}
if !filepath.IsAbs(target) {
target = filepath.Join(base, target)
}
resolved, err := filepath.Abs(target)
if err != nil {
return "", fmt.Errorf("resolve output path: %w", err)
}
return resolved, nil
}
// ensureAbsent rejects an occupied destination; compile never overwrites files.
func ensureAbsent(path string) error {
if _, err := os.Lstat(path); err == nil {
return fmt.Errorf("output already exists: %s", path)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect output: %w", err)
}
return nil
}
func selectArtifact(project gradleProject, ecosystem catalog.Ecosystem) (string, error) {
candidates := make([]archive, 0, len(project.Archives))
seen := make(map[string]bool, len(project.Archives))
for _, output := range project.Archives {
if !output.Enabled || !strings.EqualFold(filepath.Ext(output.File), ".jar") || developmentClassifier(output.Classifier) {
continue
}
if seen[output.File] {
continue
}
info, err := os.Stat(output.File)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return "", fmt.Errorf("inspect built archive %q: %w", output.File, err)
}
if !info.Mode().IsRegular() {
continue
}
packages, err := artifact.ReadInfo(output.File)
if err != nil {
return "", fmt.Errorf("verify built archive %q: %w", output.File, err)
}
matches := slices.ContainsFunc(packages, func(info artifact.PackageInfo) bool {
return supportedEcosystem(info.Ref.Ecosystem) && (ecosystem == "" || info.Ref.Ecosystem == ecosystem)
})
if matches {
seen[output.File] = true
candidates = append(candidates, output)
}
}
published := make([]archive, 0, len(candidates))
for _, candidate := range candidates {
if candidate.Published {
published = append(published, candidate)
}
}
if len(published) > 0 {
candidates = published
}
if len(candidates) == 1 {
return candidates[0].File, nil
}
if len(candidates) == 0 {
return "", fmt.Errorf("project %s produced no deployable mod JAR with matching metadata", project.Path)
}
paths := make([]string, 0, len(candidates))
for _, candidate := range candidates {
paths = append(paths, candidate.File)
}
slices.Sort(paths)
return "", fmt.Errorf("project %s produced multiple deployable mod JARs: %s", project.Path, strings.Join(paths, ", "))
}
func developmentClassifier(classifier string) bool {
classifier = strings.ToLower(classifier)
switch classifier {
case "sources", "javadoc", "dev", "dev-shadow", "api", "test", "tests", "test-fixtures":
return true
default:
return strings.HasSuffix(classifier, "-sources") || strings.HasSuffix(classifier, "-javadoc")
}
}
func publishArtifact(ctx context.Context, source, destination string) (err error) {
if err := ctx.Err(); err != nil {
return err
}
input, err := os.Open(source)
if err != nil {
return fmt.Errorf("open compiled artifact: %w", err)
}
defer func() { err = errors.Join(err, input.Close()) }()
parent := filepath.Dir(destination)
if err := os.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("create output directory: %w", err)
}
output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("create output artifact: %w", err)
}
defer func() {
if closeErr := output.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("close output artifact: %w", closeErr))
}
if err != nil {
if removeErr := os.Remove(destination); removeErr != nil {
err = errors.Join(err, fmt.Errorf("remove partial output: %w", removeErr))
}
}
}()
if _, err := io.Copy(output, contextReader{ctx: ctx, reader: input}); err != nil {
return fmt.Errorf("copy compiled artifact: %w", err)
}
return ctx.Err()
}
type contextReader struct {
ctx context.Context
reader io.Reader
}
func (r contextReader) Read(buffer []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
return r.reader.Read(buffer)
}
package compile
import (
"context"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
var githubReference = regexp.MustCompile(`^[A-Za-z0-9_-]+/[A-Za-z0-9_.-]+$`)
func parseRemote(source string) (string, error) {
if githubReference.MatchString(source) {
owner, repository, _ := strings.Cut(source, "/")
if repository == "." || repository == ".." {
return "", fmt.Errorf("invalid repository reference %q", source)
}
return "https://github.com/" + owner + "/" + strings.TrimSuffix(repository, ".git") + ".git", nil
}
remote, err := url.Parse(source)
if err != nil {
return "", fmt.Errorf("parse repository url: %w", err)
}
if remote.RawQuery != "" || remote.Fragment != "" || remote.Opaque != "" {
return "", fmt.Errorf("repository url must not contain a query, fragment, or opaque transport")
}
switch remote.Scheme {
case "https", "http", "ssh", "git":
if remote.Host == "" || strings.Trim(remote.Path, "/") == "" {
return "", fmt.Errorf("repository url must identify a host and repository path")
}
case "file":
if remote.Host != "" && remote.Host != "localhost" {
return "", fmt.Errorf("file repository url must refer to the local host")
}
if !isAbsoluteFilePath(remote.Path) {
return "", fmt.Errorf("file repository url must have an absolute path")
}
default:
return "", fmt.Errorf("source must be a Git repository url or GitHub owner/repo reference")
}
return remote.String(), nil
}
// isAbsoluteFilePath reports whether a file URL path is absolute. Windows paths
// carry their drive letter after a leading slash inside the URL, and no native
// absolute-path check accepts that spelling on Windows, so recognize it directly.
// The URL is handed to git unchanged either way, only its validity is judged here.
func isAbsoluteFilePath(path string) bool {
if len(path) >= 3 && path[0] == '/' && isDriveLetter(path[1]) && path[2] == ':' {
return true
}
return filepath.IsAbs(path)
}
func isDriveLetter(c byte) bool {
return ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z')
}
// refSuffix describes the requested ref for the clone narration lines, so the
// user can see which branch or tag the checkout landed on.
func refSuffix(branch, tag string) string {
switch {
case branch != "":
return " (branch " + branch + ")"
case tag != "":
return " (tag " + tag + ")"
default:
return ""
}
}
// clone fetches remote into dir. Its raw output goes to output rather than
// straight to the terminal, so a large checkout stays quiet and a rejected
// remote is reported with git's own reason.
func clone(ctx context.Context, remote, dir, branch, tag string, output *processLog) error {
git, err := exec.LookPath("git")
if err != nil {
return fmt.Errorf("find git: %w", err)
}
env := cloneEnvironment()
// run labels one git invocation; a failure quotes the label along with the
// tail of git's own output.
run := func(label string, args []string) error {
cmd := exec.CommandContext(ctx, git, args...)
cmd.Env = env
cmd.Stdout = output
cmd.Stderr = output
cmd.WaitDelay = 5 * time.Second
configureProcess(cmd)
err := cmd.Run()
output.flush()
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return reportFailure(output, fmt.Errorf("%s: %w", label, err))
}
return nil
}
// git resolves --branch under refs/heads before refs/tags, so a tag named
// like a branch would clone the branch. When a ref is requested, clone
// without a checkout, resolve the exact namespace, and only then check
// out that commit and initialize the submodules.
args := []string{"-c", "protocol.ext.allow=never", "clone"}
if branch == "" && tag == "" {
args = append(args, "--recurse-submodules")
} else {
args = append(args, "--no-checkout")
}
args = append(args, "--", remote, dir)
if err := run("clone repository", args); err != nil {
return err
}
if branch == "" && tag == "" {
return nil
}
// A clone keeps the fetched branches under the origin remote and copies the
// tags verbatim, so branches resolve through refs/remotes/origin and tags
// through refs/tags.
kind, namespace := "branch", "refs/remotes/origin/"
if branch == "" {
kind, namespace = "tag", "refs/tags/"
}
name := branch
if name == "" {
name = tag
}
ref := namespace + name
if err := run(fmt.Sprintf("remote has no %s %q", kind, name),
[]string{"-c", "protocol.ext.allow=never", "-C", dir, "show-ref", "--verify", "--quiet", ref},
); err != nil {
return err
}
if err := run(fmt.Sprintf("checkout %s %s", kind, name),
[]string{"-c", "protocol.ext.allow=never", "-C", dir, "checkout", "--detach", ref},
); err != nil {
return err
}
return run("initialize submodules",
[]string{"-c", "protocol.ext.allow=never", "-C", dir, "submodule", "update", "--init", "--recursive"},
)
}
// cloneEnvironment makes every git invocation of the clone non-interactive. The
// commands run in their own process group, so a prompt waiting on the terminal
// is stopped by the kernel (SIGTTIN) with no way to answer it and the command
// hangs until Ctrl+C; prompts instead fail fast. HTTPS credentials honor
// GIT_TERMINAL_PROMPT, host-key and passphrase prompts honor batch-mode ssh,
// and a user who configured their own GIT_SSH_COMMAND or GIT_SSH keeps it.
func cloneEnvironment() []string {
env := replaceEnvironment(os.Environ(), "GIT_TERMINAL_PROMPT", "0")
if _, configured := os.LookupEnv("GIT_SSH_COMMAND"); !configured {
if _, configured := os.LookupEnv("GIT_SSH"); !configured {
env = append(env, "GIT_SSH_COMMAND=ssh -o BatchMode=yes")
}
}
return env
}
package compile
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/mclucy/lucy/catalog"
_ "embed"
"github.com/Masterminds/semver/v3"
"github.com/mclucy/lucy/internal/buildrepo"
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/internal/toolchain"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/report"
)
//go:embed compile_model.gradle
var inspectionScript string
type gradleRunner struct {
dir string
executable string
arguments []string
env []string
version string
modelPath string
daemon toolchain.JavaInstallation
progress io.Writer
logger *log.Logger
reporter *report.Reporter
}
type gradleModel struct {
Projects []gradleProject `json:"projects"`
}
type gradleProject struct {
Path string `json:"path"`
Ecosystems []catalog.Ecosystem `json:"ecosystems"`
HasModMetadata bool `json:"hasModMetadata"`
BuildTask string `json:"buildTask"`
Compilers []compiler `json:"compilers"`
Archives []archive `json:"archives"`
}
type compiler struct {
Task string `json:"task"`
Major int `json:"major"`
Executable string `json:"executable"`
Release int `json:"release"`
Target string `json:"target"`
}
type archive struct {
File string `json:"file"`
Classifier string `json:"classifier"`
Published bool `json:"published"`
Enabled bool `json:"enabled"`
}
func newGradleRunner(
build buildrepo.GradleBuild,
inventory toolchain.Inventory,
temporary string,
progress io.Writer,
logger *log.Logger,
reporter *report.Reporter,
) (gradleRunner, error) {
runner := gradleRunner{
dir: build.Dir,
version: "unknown",
progress: progress,
logger: logger,
reporter: reporter,
}
version := ""
if build.Wrapper != nil {
wrapper := build.Wrapper
var scriptMode os.FileMode
for _, path := range []string{
wrapper.Script,
wrapper.Jar,
wrapper.Properties,
} {
if path == "" {
return runner, errors.New("repository Gradle wrapper is incomplete")
}
info, err := os.Stat(path)
if err != nil {
return runner, fmt.Errorf("inspect Gradle wrapper component %q: %w", path, err)
}
if !info.Mode().IsRegular() {
return runner, fmt.Errorf("gradle wrapper component is not a regular file: %s", path)
}
if path == wrapper.Script {
scriptMode = info.Mode()
}
}
if wrapper.DistributionURL == "" {
return runner, errors.New("gradle wrapper has no distribution url")
}
version = wrapper.Version
runner.executable = wrapper.Script
switch {
case runtime.GOOS == "windows":
runner.executable = "cmd.exe"
runner.arguments = []string{"/d", "/c", wrapper.Script}
case scriptMode.Perm()&0o111 == 0:
// Repositories sometimes commit the wrapper without the executable
// bit, so Lucy names the interpreter its shebang asks for.
runner.executable = wrapperInterpreter(wrapper.Script)
runner.arguments = []string{wrapper.Script}
}
} else if inventory.Gradle != nil {
runner.executable = inventory.Gradle.Path
version = inventory.Gradle.Version
extension := strings.ToLower(filepath.Ext(runner.executable))
if runtime.GOOS == "windows" && (extension == ".bat" || extension == ".cmd") {
runner.arguments = []string{"/d", "/c", runner.executable}
runner.executable = "cmd.exe"
}
} else {
return runner, errors.Join(
errors.New("repository has no Gradle wrapper and no local Gradle installation was found"),
errors.Join(inventory.Problems...),
)
}
if version != "" {
runner.version = version
}
launcher, daemon, err := selectJVMs(build, inventory, version)
if err != nil {
return runner, errors.Join(err, errors.Join(inventory.Problems...))
}
runner.daemon = daemon
logger.Debug("Gradle JVMs selected", "launcher", launcher.Java, "daemon", daemon.Java,
"reason", "Gradle compatibility and project toolchain requirements")
initPath := filepath.Join(temporary, "lucy-init.gradle")
if err := os.WriteFile(initPath, []byte(inspectionScript), 0o600); err != nil {
return runner, fmt.Errorf("write Gradle inspection script: %w", err)
}
runner.modelPath = filepath.Join(temporary, "gradle-model.json")
runner.arguments = append(
runner.arguments,
"--no-daemon", "--console=plain", "--no-configure-on-demand", "--init-script", initPath,
"-Dorg.gradle.java.installations.auto-download=false",
)
// --no-configuration-cache exists since Gradle 6.6. A wrapper distribution
// with a custom file name leaves the version unknown, and Gradle refuses
// an unsupported flag before running any task, so the flag is passed only
// when the version is known to accept it.
if parsed, err := semver.NewVersion(version); err == nil && parsed.GreaterThanEqual(semver.MustParse("6.6.0")) {
runner.arguments = append(runner.arguments, "--no-configuration-cache")
}
if build.Daemon == nil {
runner.arguments = append(runner.arguments, "-Dorg.gradle.java.home="+daemon.Home)
}
homes := make([]string, 0, len(inventory.Java))
for _, java := range inventory.Java {
if java.Javac != "" {
homes = append(homes, java.Home)
}
}
runner.env = replaceEnvironment(os.Environ(), "JAVA_HOME", launcher.Home)
installationPathsKey := "ORG_GRADLE_PROJECT_org.gradle.java.installations.paths"
if _, configured := os.LookupEnv(installationPathsKey); !configured && len(homes) > 0 {
runner.env = replaceEnvironment(runner.env, installationPathsKey, strings.Join(homes, ","))
}
runner.env = replaceEnvironment(runner.env, "LUCY_COMPILE_MODEL", runner.modelPath)
runner.env = replaceEnvironment(runner.env, "PATH", filepath.Dir(launcher.Java)+string(os.PathListSeparator)+os.Getenv("PATH"))
return runner, nil
}
// inspect evaluates the build model of every project so Lucy can choose one.
func (r gradleRunner) inspect(ctx context.Context) (gradleModel, error) {
writeProgress(r.progress, "Evaluating Gradle "+r.version+" build")
return r.execute(ctx, "Gradle build model", ":lucyCompileModel")
}
// inspectCompilers reports the compilers the selected project can use, which
// decides whether the local JDK can build it. The project's own build task
// joins the invocation as a dry run so the task graph names the compile tasks
// without running any of them: the JDK check must happen before the build.
func (r gradleRunner) inspectCompilers(ctx context.Context, project gradleProject) (gradleProject, error) {
writeProgress(r.progress, "Checking toolchain for "+nameProject(project.Path))
model, err := r.execute(
ctx,
"Gradle compiler inspection",
project.BuildTask,
":lucyCompileModel",
"-Plucy.compile.project="+project.Path,
"--dry-run",
)
if err != nil {
return gradleProject{}, err
}
for _, candidate := range model.Projects {
if candidate.Path == project.Path {
return candidate, nil
}
}
return gradleProject{}, fmt.Errorf("selected project %s disappeared from the build", project.Path)
}
// build runs task on the selected project and returns the archives it produced.
func (r gradleRunner) build(ctx context.Context, task string) (gradleModel, error) {
writeProgress(r.progress, "Building "+task)
return r.execute(ctx, "Gradle "+task, task, ":lucyCompileModel")
}
// execute runs label along with tasks. The label names only the work the user
// asked for: tasks also carries Lucy's own inspection task and its -P
// properties, which are implementation detail rather than something to report.
func (r gradleRunner) execute(ctx context.Context, label string, tasks ...string) (gradleModel, error) {
if err := os.Remove(r.modelPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return gradleModel{}, fmt.Errorf("remove previous Gradle model: %w", err)
}
arguments := make([]string, 0, len(r.arguments)+len(tasks))
arguments = append(arguments, r.arguments...)
arguments = append(arguments, tasks...)
output := newProcessLog("gradle", r.logger, r.reporter)
cmd := exec.CommandContext(ctx, r.executable, arguments...)
cmd.Dir = r.dir
cmd.Env = r.env
cmd.Stdout = output
cmd.Stderr = output
cmd.WaitDelay = 5 * time.Second
configureProcess(cmd)
r.logger.Info("Gradle stage started", "stage", label, "version", r.version)
r.logger.Debug("Gradle invocation prepared", "executable", r.executable, "tasks", tasks, "directory", r.dir)
err := cmd.Run()
output.flush()
if err != nil {
if ctx.Err() != nil {
return gradleModel{}, ctx.Err()
}
return gradleModel{}, reportFailure(
output,
fmt.Errorf("run %s: %w", label, err),
)
}
data, err := os.ReadFile(r.modelPath)
if err != nil {
return gradleModel{}, fmt.Errorf("read evaluated Gradle model: %w", err)
}
var model gradleModel
if err := json.Unmarshal(data, &model); err != nil {
return model, fmt.Errorf("decode evaluated Gradle model: %w", err)
}
r.logger.Info("Gradle stage completed", "stage", label, "projects", len(model.Projects))
return model, nil
}
// nameProject names a Gradle project for user-facing text. The root project
// carries the empty-looking path ":", which reads badly inside a sentence.
func nameProject(path string) string {
if path == "" || path == ":" {
return "the root project"
}
return path
}
func selectProject(ctx context.Context, model gradleModel, opts *options, gate cli.PromptGate) (gradleProject, error) {
candidates := make([]gradleProject, 0, len(model.Projects))
ecosystem := catalog.Ecosystem(opts.ecosystem)
for _, project := range model.Projects {
if opts.project != "" && project.Path != opts.project {
continue
}
if ecosystem != "" && !slices.Contains(project.Ecosystems, ecosystem) {
continue
}
hasModArchive := slices.ContainsFunc(project.Archives, func(output archive) bool {
return output.Enabled && filepath.Ext(output.File) == ".jar" && !developmentClassifier(output.Classifier)
})
if hasModArchive && slices.ContainsFunc(project.Ecosystems, supportedEcosystem) {
candidates = append(candidates, project)
}
}
if slices.ContainsFunc(candidates, func(project gradleProject) bool { return project.HasModMetadata }) {
candidates = slices.DeleteFunc(candidates, func(project gradleProject) bool { return !project.HasModMetadata })
}
if len(candidates) == 0 {
return gradleProject{}, errors.New("no matching Forge, NeoForge, or Fabric mod project was found")
}
if len(candidates) > 1 {
// An explicit --project never reaches this branch, so the prompt only
// ever replaces an absent flag.
if opts.project == "" && gate.Available() {
picked, err := promptModProject(ctx, candidates, gate)
if err != nil {
return gradleProject{}, err
}
opts.project = picked.Path
candidates = []gradleProject{picked}
} else {
paths := make([]string, 0, len(candidates))
for _, project := range candidates {
paths = append(paths, project.Path+" ["+joinEcosystems(project.Ecosystems)+"]")
}
return gradleProject{}, fmt.Errorf("multiple mod projects found; select --ecosystem or --project: %s", strings.Join(paths, ", "))
}
}
project := candidates[0]
if project.BuildTask == "" {
return project, fmt.Errorf("project %s has neither a build nor an assemble task", project.Path)
}
return project, nil
}
func joinEcosystems(ecosystems []catalog.Ecosystem) string {
values := make([]string, 0, len(ecosystems))
for _, ecosystem := range ecosystems {
values = append(values, string(ecosystem))
}
return strings.Join(values, ", ")
}
func replaceEnvironment(environment []string, key, value string) []string {
for i, entry := range environment {
name, _, _ := strings.Cut(entry, "=")
if strings.EqualFold(name, key) {
environment[i] = key + "=" + value
return environment
}
}
return append(environment, key+"="+value)
}
package compile
import (
"bufio"
"os"
"os/exec"
"path/filepath"
"strings"
)
// fallbackShell interprets Gradle wrappers that declare no usable shebang. It
// is the shell a wrapper without a shebang would expect anyway.
const fallbackShell = "/bin/sh"
// wrapperInterpreter returns the shell to run a Gradle wrapper that Lucy
// cannot execute directly. Gradle ships a bash script before 8.2 and a POSIX
// one from 8.2 on, so the shebang is the only reliable statement of which
// dialect the wrapper is written in; the fallback shell rejects the bash one
// wherever it is dash. Reading it here is a fallback, not the usual path: an
// executable wrapper is started directly and lets the system read it instead.
func wrapperInterpreter(script string) string {
if shell, ok := shebangShell(script); ok {
return shell
}
return fallbackShell
}
// shebangShell resolves the interpreter a script's shebang line names, and
// reports whether the script declares one that is present on this machine.
func shebangShell(script string) (string, bool) {
line, err := readFirstLine(script)
if err != nil {
return "", false
}
line, declared := strings.CutPrefix(line, "#!")
if !declared {
return "", false
}
fields := strings.Fields(line)
if len(fields) > 0 && filepath.Base(fields[0]) == "env" {
fields = fields[1:]
}
for _, field := range fields {
if strings.HasPrefix(field, "-") {
continue
}
resolved, err := exec.LookPath(field)
return resolved, err == nil
}
return "", false
}
// readFirstLine returns the first line of a script without its line ending.
func readFirstLine(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
line, err := bufio.NewReader(file).ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
package compile
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/Masterminds/semver/v3"
"github.com/mclucy/lucy/internal/buildrepo"
"github.com/mclucy/lucy/internal/toolchain"
)
func selectJVMs(
build buildrepo.GradleBuild,
inventory toolchain.Inventory,
gradleVersion string,
) (toolchain.JavaInstallation, toolchain.JavaInstallation, error) {
var gradle *semver.Version
if parsed, err := semver.NewVersion(gradleVersion); err == nil {
gradle = parsed
}
var launcher toolchain.JavaInstallation
if configured := os.Getenv("JAVA_HOME"); configured != "" {
java, err := javaAtHome(inventory, configured)
if err != nil {
return launcher, launcher, fmt.Errorf("configured JAVA_HOME: %w", err)
}
if gradleSupportsJava(gradle, java.Major) {
launcher = java
}
}
if launcher.Home == "" {
for _, java := range inventory.Java {
if gradleSupportsJava(gradle, java.Major) {
launcher = java
break
}
}
}
if launcher.Home == "" {
return launcher, launcher, fmt.Errorf("no local Java installation can launch Gradle %s", gradleVersion)
}
daemon := launcher
if build.Daemon != nil {
var found bool
for _, java := range inventory.Java {
versionMatches := build.Daemon.Major == 0 || java.Major == build.Daemon.Major
if versionMatches && vendorMatches(build.Daemon.Vendor, java.Vendor) && gradleSupportsJava(gradle, java.Major) {
daemon = java
found = true
break
}
}
if !found {
description := fmt.Sprintf("Gradle daemon requires local Java %d", build.Daemon.Major)
if build.Daemon.Vendor != "" {
description += " from " + build.Daemon.Vendor
}
return launcher, daemon, errors.New(description)
}
} else if build.JavaHome != "" {
var err error
daemon, err = javaAtHome(inventory, build.JavaHome)
if err != nil {
return launcher, daemon, fmt.Errorf("configured Gradle java home: %w", err)
}
}
if !gradleSupportsJava(gradle, daemon.Major) {
return launcher, daemon, fmt.Errorf("gradle %s cannot run its daemon on Java %d", gradleVersion, daemon.Major)
}
return launcher, daemon, nil
}
func javaAtHome(inventory toolchain.Inventory, home string) (toolchain.JavaInstallation, error) {
resolved, err := filepath.EvalSymlinks(home)
if err != nil {
return toolchain.JavaInstallation{}, fmt.Errorf("resolve Java home %q: %w", home, err)
}
for _, java := range inventory.Java {
if java.Home == filepath.Clean(resolved) {
return java, nil
}
}
return toolchain.JavaInstallation{}, fmt.Errorf("no usable Java installation at %q", home)
}
func configuredGradleJavaHome(build buildrepo.GradleBuild, userHome string) (string, error) {
home := build.JavaHome
if userHome == "" {
return home, nil
}
path := filepath.Join(userHome, "gradle.properties")
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return home, nil
}
if err != nil {
return "", fmt.Errorf("read user Gradle properties: %w", err)
}
properties, err := buildrepo.ParseProperties(string(data))
if err != nil {
return "", fmt.Errorf("decode user Gradle properties: %w", err)
}
if configured, exists := properties["org.gradle.java.home"]; exists {
home = configured
}
if home != "" && !filepath.IsAbs(home) {
home = filepath.Join(build.Dir, home)
}
return home, nil
}
var minimumGradleByJava = [...]uint64{
8: 200, 9: 403, 10: 407, 11: 500, 12: 504,
13: 600, 14: 603, 15: 607, 16: 700, 17: 703,
18: 705, 19: 706, 20: 803, 21: 805, 22: 808,
23: 810, 24: 814, 25: 901, 26: 904, 27: 908,
}
func gradleSupportsJava(version *semver.Version, major int) bool {
if major < 8 || major >= len(minimumGradleByJava) {
return false
}
if version == nil {
return true
}
if version.Major() >= 9 && major < 17 {
return false
}
return version.Major()*100+version.Minor() >= minimumGradleByJava[major]
}
func vendorMatches(required, actual string) bool {
required = strings.ToLower(required)
actual = strings.ToLower(actual)
if required == "" || required == "any vendor" {
return true
}
for _, pair := range [][2]string{
{"adoptium", "eclipse"},
{"azul", "azul"},
{"zulu", "azul"},
{"amazon", "amazon"},
{"bellsoft", "bellsoft"},
{"oracle", "oracle"},
{"microsoft", "microsoft"},
{"graal", "graal"},
{"ibm", "ibm"},
} {
if strings.Contains(required, pair[0]) {
return strings.Contains(actual, pair[1])
}
}
return strings.Contains(actual, required)
}
func checkCompiler(project gradleProject, daemon toolchain.JavaInstallation) error {
for _, requirement := range project.Compilers {
major := daemon.Major
executable := daemon.Javac
if requirement.Executable != "" {
executable = requirement.Executable
major = requirement.Major
}
if executable == "" {
return fmt.Errorf("%s needs javac, but the Gradle JVM is a runtime-only installation", requirement.Task)
}
info, err := os.Stat(executable)
if err != nil {
return fmt.Errorf("inspect compiler for %s: %w", requirement.Task, err)
}
if !info.Mode().IsRegular() || (runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0) {
return fmt.Errorf("compiler for %s is not executable: %s", requirement.Task, executable)
}
target := requirement.Release
if target == 0 {
if parsed, err := strconv.Atoi(strings.TrimPrefix(requirement.Target, "1.")); err == nil {
target = parsed
}
}
if target > major {
return fmt.Errorf("%s targets Java %d, but its compiler is Java %d", requirement.Task, target, major)
}
}
return nil
}
// toolchainWarnings reports projects that pin a Java toolchain no local JDK
// satisfies. Gradle enforces such a pin exactly: a newer JDK never substitutes,
// because its compiler emits newer bytecode than the project targets. Lucy turns
// Gradle's auto-provisioning off, so an unsatisfied pin otherwise surfaces as a
// "Cannot find a Java installation" error raised from inside a build script,
// naming a JDK the reader was never told to install.
//
// These are warnings rather than errors because the pin is read statically from
// a Groovy DSL that a convention plugin may also set, leaving Gradle the final
// authority. The check only moves the diagnosis ahead of the failure.
func toolchainWarnings(build buildrepo.GradleBuild, inventory toolchain.Inventory) []string {
var warnings []string
for _, project := range build.Projects {
required := project.Java.Toolchain
if required == nil || required.Major == 0 {
continue
}
var available []string
seen := map[int]bool{}
satisfied := false
for _, java := range inventory.Java {
if java.Javac == "" {
continue
}
if java.Major == required.Major && vendorMatches(required.Vendor, java.Vendor) {
satisfied = true
}
if seen[java.Major] {
continue
}
seen[java.Major] = true
available = append(available, strconv.Itoa(java.Major))
}
if satisfied {
continue
}
pin := fmt.Sprintf("%s pins a Java %d toolchain", project.Path, required.Major)
if required.Vendor != "" {
pin += " from " + required.Vendor
}
if len(available) == 0 {
pin += ", but no local JDK was found"
} else {
pin += ", but local installations are Java " + strings.Join(available, ", ")
}
location := project.BuildFile
if relative, err := filepath.Rel(build.Dir, project.BuildFile); err == nil {
location = filepath.ToSlash(relative)
}
warnings = append(warnings, location+": "+pin)
}
return warnings
}
package compile
import (
"errors"
"strings"
"sync"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/report"
)
// excerptLines bounds the tail of a failed tool quoted on the console. Git and
// Gradle both explain themselves at the end of their output.
const excerptLines = 20
// processLog captures the raw stdout and stderr of one external tool.
//
// Raw tool output is DEBUG diagnostics. A failed step reports its recent
// output even when diagnostic logging filters out DEBUG records.
//
// os/exec copies a child's stdout and stderr on separate goroutines, so every
// method is guarded.
type processLog struct {
tool string
logger *log.Logger
reporter *report.Reporter
mu sync.Mutex
partial string
recent []string
}
func newProcessLog(
tool string,
logger *log.Logger,
reporter *report.Reporter,
) *processLog {
return &processLog{tool: tool, logger: logger, reporter: reporter}
}
// Write forwards a raw chunk, splitting complete lines off for the log file
// and the excerpt while the trailing partial line waits for its terminator.
func (l *processLog) Write(chunk []byte) (int, error) {
l.consume(chunk, false)
return len(chunk), nil
}
// flush records a trailing line that arrived without a newline terminator.
func (l *processLog) flush() {
l.consume(nil, true)
}
func (l *processLog) consume(chunk []byte, flush bool) {
l.mu.Lock()
l.partial += string(chunk)
lines := strings.Split(l.partial, "\n")
l.partial = lines[len(lines)-1]
complete := lines[:len(lines)-1]
if flush && l.partial != "" {
complete = append(complete, l.partial)
l.partial = ""
}
l.recent = append(l.recent, complete...)
if len(l.recent) > excerptLines {
l.recent = l.recent[len(l.recent)-excerptLines:]
}
l.mu.Unlock()
if len(complete) > 0 {
l.logger.Debug("external tool output", "tool", l.tool, "lines", complete)
}
}
// excerpt quotes the most recent non-blank lines of tool output, or nothing
// when the tool stayed silent.
func (l *processLog) excerpt() string {
l.mu.Lock()
defer l.mu.Unlock()
lines := make([]string, 0, len(l.recent))
for _, line := range l.recent {
if trimmed := strings.TrimRight(line, "\r"); strings.TrimSpace(trimmed) != "" {
lines = append(lines, trimmed)
}
}
if len(lines) == 0 {
return ""
}
return "\n" + strings.Join(lines, "\n")
}
// reportFailure shows the tool's own account of a failure and returns err
// unchanged. The excerpt is its own block rather than part of the error, where
// a run of raw lines would be reflowed into a single paragraph.
func reportFailure(output *processLog, err error) error {
if excerpt := output.excerpt(); excerpt != "" {
output.reporter.Error(
errors.New(output.tool + " reported:\n" + indent(excerpt)),
)
}
return err
}
// indent prefixes every line so a block of quoted tool output reads as one.
func indent(block string) string {
lines := strings.Split(strings.Trim(block, "\n"), "\n")
for i, line := range lines {
lines[i] = " " + line
}
return strings.Join(lines, "\n")
}
//go:build !windows
package compile
import (
"errors"
"os"
"os/exec"
"syscall"
)
func configureProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return os.ErrProcessDone
}
return err
}
}
package compile
import (
"context"
"charm.land/huh/v2"
"github.com/mclucy/lucy/internal/cli"
)
// promptModProject asks which of several mod projects to compile. `huh` renders
// on stderr, so stdout keeps carrying only the artifact path. Aborting the
// selection cancels the compilation; there is no default to fall back to.
func promptModProject(
ctx context.Context,
candidates []gradleProject,
gate cli.PromptGate,
) (gradleProject, error) {
// gradleProject is not comparable (it holds slices), so the select carries
// the candidate index instead of the struct.
options := make([]huh.Option[int], 0, len(candidates))
for i, project := range candidates {
options = append(options, huh.NewOption(describeModProject(project), i))
}
var picked int
form := huh.NewForm(huh.NewGroup(
huh.NewSelect[int]().
Title("Select a mod project").
Description("Multiple Forge, NeoForge, or Fabric mod projects were found.").
Options(options...).
Filtering(true).
Value(&picked),
)).
WithInput(gate.Input()).
WithOutput(gate.Output())
if err := cli.NormalizePromptError(ctx, form.RunWithContext(ctx)); err != nil {
return gradleProject{}, err
}
return candidates[picked], nil
}
// describeModProject labels a candidate for the select prompt. Unlike the
// non-interactive error, it names the root project in words.
func describeModProject(project gradleProject) string {
return nameProject(project.Path) + " [" + joinEcosystems(project.Ecosystems) + "]"
}
package docs
import (
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/spf13/cobra"
)
// NewCommand returns the hidden documentation command.
func NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "docs",
Short: help.DocsShort,
Hidden: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
cli.MarkHelpOnly(cmd)
cmd.AddCommand(
newListCommand(),
newGrepCommand(),
newReadCommand(),
)
return cmd
}
package docs
import (
"fmt"
"path"
"strings"
docscontent "github.com/mclucy/lucy/docs"
)
type page struct {
slug string
content []byte
}
func loadPages() ([]page, error) {
corpus, err := docscontent.Load()
if err != nil {
return nil, err
}
pages := make([]page, len(corpus))
for i, entry := range corpus {
pages[i] = page{slug: entry.Slug, content: entry.Content}
}
return pages, nil
}
func findPage(pages []page, slug string) (page, bool) {
for _, candidate := range pages {
if candidate.slug == slug {
return candidate, true
}
}
return page{}, false
}
func normalizeSlug(raw string) (string, error) {
slug := strings.TrimSpace(raw)
slug = strings.TrimPrefix(slug, "/")
slug = strings.TrimSuffix(slug, ".mdx")
slug = path.Clean(slug)
if slug == "." || slug == ".." || strings.HasPrefix(slug, "../") {
return "", fmt.Errorf("invalid documentation page %q", raw)
}
return slug, nil
}
package docs
import (
"errors"
"fmt"
"strings"
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/spf13/cobra"
)
type grepOptions struct {
ignoreCase bool
lineNumber bool
filesWithMatches bool
filesWithoutMatch bool
count bool
invert bool
fixed bool
word bool
wholeLine bool
patterns []string
before int
after int
context int
noFilename bool
withFilename bool
quiet bool
}
func newGrepCommand() *cobra.Command {
var opts grepOptions
cmd := &cobra.Command{
Use: "grep <pattern> [page...]",
Short: help.DocsGrepShort,
Long: help.DocsGrepLong,
Args: func(_ *cobra.Command, args []string) error {
if len(opts.patterns) == 0 && len(args) == 0 {
return cli.ExitWith(2, errors.New("missing pattern"))
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return runGrep(cmd, args, &opts)
},
}
cmd.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
return cli.ExitWith(2, err)
})
// Grep uses -h for --no-filename, so reserve the long flag for help.
cmd.Flags().Bool("help", false, "help for grep")
cmd.Flags().BoolVarP(
&opts.ignoreCase,
"ignore-case",
"i",
false,
"Ignore case distinctions",
)
cmd.Flags().BoolVarP(
&opts.lineNumber,
"line-number",
"n",
false,
"Prefix each output line with its line number",
)
cmd.Flags().BoolVarP(
&opts.filesWithMatches,
"files-with-matches",
"l",
false,
"Print only page slugs with matches",
)
cmd.Flags().BoolVarP(
&opts.filesWithoutMatch,
"files-without-match",
"L",
false,
"Print only page slugs without matches",
)
cmd.Flags().BoolVarP(
&opts.count,
"count",
"c",
false,
"Print a count of matching lines per page",
)
cmd.Flags().BoolVarP(
&opts.invert,
"invert-match",
"v",
false,
"Select non-matching lines",
)
cmd.Flags().BoolVarP(
&opts.fixed,
"fixed-strings",
"F",
false,
"Interpret patterns as fixed strings",
)
cmd.Flags().BoolVarP(
&opts.word,
"word-regexp",
"w",
false,
"Match whole words only",
)
cmd.Flags().BoolVarP(
&opts.wholeLine,
"line-regexp",
"x",
false,
"Match whole lines only",
)
cmd.Flags().StringArrayVarP(
&opts.patterns,
"regexp",
"e",
nil,
"Use PATTERN for matching; repeatable",
)
cmd.Flags().IntVarP(
&opts.after,
"after-context",
"A",
0,
"Print NUM lines after a match",
)
cmd.Flags().IntVarP(
&opts.before,
"before-context",
"B",
0,
"Print NUM lines before a match",
)
cmd.Flags().IntVarP(
&opts.context,
"context",
"C",
0,
"Print NUM lines before and after a match",
)
cmd.Flags().BoolVarP(
&opts.noFilename,
"no-filename",
"h",
false,
"Do not prefix output with page slugs",
)
cmd.Flags().BoolVarP(
&opts.withFilename,
"with-filename",
"H",
false,
"Prefix output with page slugs",
)
cmd.Flags().BoolVarP(
&opts.quiet,
"quiet",
"q",
false,
"Suppress output; exit 0 on the first match",
)
return cmd
}
func runGrep(cmd *cobra.Command, args []string, opts *grepOptions) error {
if opts.filesWithMatches && opts.filesWithoutMatch {
return cli.ExitWith(2, errors.New("-l and -L are mutually exclusive"))
}
if opts.before < 0 || opts.after < 0 || opts.context < 0 {
return cli.ExitWith(2, errors.New("context values must not be negative"))
}
patterns := opts.patterns
pageArgs := args
if len(patterns) == 0 {
patterns = []string{args[0]}
pageArgs = args[1:]
}
match, err := newLineMatcher(patterns, opts)
if err != nil {
return cli.ExitWith(2, fmt.Errorf("invalid pattern: %w", err))
}
pages, err := loadPages()
if err != nil {
return cli.ExitWith(2, err)
}
selected := pages
if len(pageArgs) > 0 {
selected = make([]page, 0, len(pageArgs))
for _, raw := range pageArgs {
slug, err := normalizeSlug(raw)
if err != nil {
return cli.ExitWith(2, err)
}
selectedPage, ok := findPage(pages, slug)
if !ok {
return cli.ExitWith(2, fmt.Errorf("unknown documentation page %q", raw))
}
selected = append(selected, selectedPage)
}
}
showFilename := opts.withFilename ||
(!opts.noFilename && len(selected) > 1)
out := cmd.OutOrStdout()
success := false
for _, selectedPage := range selected {
lines := splitLines(selectedPage.content)
matched := make([]bool, len(lines))
anyMatch := false
for index, line := range lines {
hit := match(line)
if opts.invert {
hit = !hit
}
matched[index] = hit
if hit {
anyMatch = true
}
}
if opts.quiet {
if anyMatch {
return nil
}
continue
}
switch {
case opts.filesWithMatches:
if anyMatch {
success = true
if _, err := fmt.Fprintln(out, selectedPage.slug); err != nil {
return err
}
}
case opts.filesWithoutMatch:
if !anyMatch {
success = true
if _, err := fmt.Fprintln(out, selectedPage.slug); err != nil {
return err
}
}
case opts.count:
if anyMatch {
success = true
}
if err := writeCount(out, selectedPage.slug, matched, showFilename); err != nil {
return err
}
default:
if anyMatch {
success = true
if err := writeMatches(
out,
selectedPage.slug,
lines,
matched,
opts,
showFilename,
); err != nil {
return err
}
}
}
}
if success {
return nil
}
return cli.SilentExit(1)
}
func splitLines(content []byte) []string {
if len(content) == 0 {
return nil
}
text := strings.TrimSuffix(string(content), "\n")
lines := strings.Split(text, "\n")
for index := range lines {
lines[index] = strings.TrimSuffix(lines[index], "\r")
}
return lines
}
package docs
import (
"regexp"
"strings"
)
type lineMatcher func(string) bool
func newLineMatcher(patterns []string, opts *grepOptions) (lineMatcher, error) {
if opts.fixed {
return newFixedMatcher(patterns, opts), nil
}
return newRegexpMatcher(patterns, opts)
}
func newFixedMatcher(patterns []string, opts *grepOptions) lineMatcher {
needles := make([]string, len(patterns))
for index, pattern := range patterns {
if opts.ignoreCase {
pattern = strings.ToLower(pattern)
}
needles[index] = pattern
}
return func(line string) bool {
if opts.ignoreCase {
line = strings.ToLower(line)
}
for _, needle := range needles {
if fixedLineMatches(line, needle, opts.word, opts.wholeLine) {
return true
}
}
return false
}
}
func fixedLineMatches(line, needle string, word, wholeLine bool) bool {
if needle == "" {
return !wholeLine || line == ""
}
if wholeLine {
return line == needle
}
if !word {
return strings.Contains(line, needle)
}
for start := 0; start <= len(line); {
index := strings.Index(line[start:], needle)
if index < 0 {
return false
}
index += start
before := index == 0 || !isWordByte(line[index-1])
end := index + len(needle)
after := end == len(line) || !isWordByte(line[end])
if before && after {
return true
}
start = index + 1
}
return false
}
func isWordByte(value byte) bool {
return value == '_' ||
(value >= '0' && value <= '9') ||
(value >= 'a' && value <= 'z') ||
(value >= 'A' && value <= 'Z')
}
func newRegexpMatcher(patterns []string, opts *grepOptions) (lineMatcher, error) {
expressions := make([]*regexp.Regexp, 0, len(patterns))
for _, pattern := range patterns {
expression := pattern
switch {
case opts.wholeLine:
expression = `^(?:` + expression + `)$`
case opts.word:
// Boundary matching is part of the regexp so alternatives are
// retried when a shorter match fails a boundary.
expression = `(?:^|\W)(?:` + expression + `)(?:\W|$)`
}
if opts.ignoreCase {
expression = `(?i)` + expression
}
compiled, err := regexp.Compile(expression)
if err != nil {
return nil, err
}
expressions = append(expressions, compiled)
}
return func(line string) bool {
for _, compiled := range expressions {
if compiled.MatchString(line) {
return true
}
}
return false
}, nil
}
package docs
import (
"fmt"
"io"
)
func writeCount(out io.Writer, slug string, matched []bool, showFilename bool) error {
count := 0
for _, hit := range matched {
if hit {
count++
}
}
if showFilename {
_, err := fmt.Fprintf(out, "%s:%d\n", slug, count)
return err
}
_, err := fmt.Fprintf(out, "%d\n", count)
return err
}
type matchPrinter struct {
out io.Writer
slug string
showFilename bool
showLineNumber bool
}
func (p *matchPrinter) line(text string, number int, match bool) error {
separator := "-"
if match {
separator = ":"
}
switch {
case p.showFilename && p.showLineNumber:
_, err := fmt.Fprintf(
p.out,
"%s%s%d%s%s\n",
p.slug,
separator,
number,
separator,
text,
)
return err
case p.showFilename:
_, err := fmt.Fprintf(p.out, "%s%s%s\n", p.slug, separator, text)
return err
case p.showLineNumber:
_, err := fmt.Fprintf(p.out, "%d%s%s\n", number, separator, text)
return err
default:
_, err := fmt.Fprintln(p.out, text)
return err
}
}
func writeMatches(
out io.Writer,
slug string,
lines []string,
matched []bool,
opts *grepOptions,
showFilename bool,
) error {
before := opts.before
after := opts.after
if opts.context > 0 {
before = max(before, opts.context)
after = max(after, opts.context)
}
printer := &matchPrinter{
out: out,
slug: slug,
showFilename: showFilename,
showLineNumber: opts.lineNumber,
}
if before == 0 && after == 0 {
for index, hit := range matched {
if !hit {
continue
}
if err := printer.line(lines[index], index+1, true); err != nil {
return err
}
}
return nil
}
type lineRange struct {
start int
end int
}
ranges := make([]lineRange, 0)
for index, hit := range matched {
if !hit {
continue
}
start := max(0, index-before)
end := min(len(lines)-1, index+after)
if len(ranges) > 0 && start <= ranges[len(ranges)-1].end+1 {
if end > ranges[len(ranges)-1].end {
ranges[len(ranges)-1].end = end
}
continue
}
ranges = append(
ranges,
lineRange{
start: start,
end: end,
},
)
}
for rangeIndex, group := range ranges {
if rangeIndex > 0 {
if _, err := fmt.Fprintln(out, "--"); err != nil {
return err
}
}
for lineIndex := group.start; lineIndex <= group.end; lineIndex++ {
if err := printer.line(
lines[lineIndex],
lineIndex+1,
matched[lineIndex],
); err != nil {
return err
}
}
}
return nil
}
package docs
import (
"fmt"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/spf13/cobra"
)
func newListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: help.DocsListShort,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
pages, err := loadPages()
if err != nil {
return err
}
out := cmd.OutOrStdout()
for _, page := range pages {
if _, err := fmt.Fprintln(out, page.slug); err != nil {
return err
}
}
return nil
},
}
}
package docs
import (
"fmt"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/spf13/cobra"
)
func newReadCommand() *cobra.Command {
return &cobra.Command{
Use: "read <page> [page...]",
Short: help.DocsReadShort,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
pages, err := loadPages()
if err != nil {
return err
}
// Resolve every argument before writing, so a mistyped page name
// never leaves half the request on stdout.
selected := make([]page, 0, len(args))
for _, arg := range args {
slug, err := normalizeSlug(arg)
if err != nil {
return err
}
found, ok := findPage(pages, slug)
if !ok {
return fmt.Errorf(
"unknown documentation page %q; run lucy docs list",
arg,
)
}
selected = append(selected, found)
}
out := cmd.OutOrStdout()
for _, current := range selected {
if len(selected) > 1 {
if _, err := fmt.Fprintf(out, "===== %s =====\n", current.slug); err != nil {
return err
}
}
if _, err := out.Write(current.content); err != nil {
return err
}
if len(current.content) > 0 && current.content[len(current.content)-1] != '\n' {
if _, err := fmt.Fprintln(out); err != nil {
return err
}
}
if len(selected) > 1 {
if _, err := fmt.Fprintln(out); err != nil {
return err
}
}
}
return nil
},
}
}
// Package file adapts the tracked-file command surface onto cobra.
//
// Every subcommand forwards its explicit choices to the filetrack service and
// prints the result; the adapter holds no tracking policy, digest math, or
// content-address storage logic of its own.
package file
import (
"github.com/mclucy/lucy/internal/cli"
"github.com/spf13/cobra"
)
// Flag names of the file subcommands. Output flags come from the root
// persistent flags and are read through their shared constants.
const (
flagAll = "all"
flagFromSource = "from-source"
flagImmutable = "immutable"
flagNoHash = "no-hash"
flagRecoverURL = "recover-url"
flagRevision = "revision"
)
const (
FileShort = "Track files, accept revisions, and restore recorded content"
TrackShort = "Start tracking an existing file"
UntrackShort = "Stop tracking a file"
UpdateShort = "Accept current or fresh contents as the accepted revision"
RecoverShort = "Restore a file to its accepted contents"
RevertShort = "Restore and accept a historical revision"
LockShort = "Accept current contents and mark the file immutable"
UnlockShort = "Stop warning about changes to a file"
ListShort = "Show the state of tracked files"
HistoryShort = "List tracked file events and revisions"
)
const (
FileLong = "Run from a server workspace. Paths are relative to the " +
"directory containing lucy.yaml."
TrackLong = "Snapshot the current contents of existing regular files " +
"into the local content store and declare them in lucy.yaml. One " +
"invocation accepts every path atomically. By default each entry " +
"is mutable with an accepted hash; --immutable requires an " +
"accepted hash and raises warnings on deviation, --no-hash tracks " +
"presence only, and --recover-url records an explicit recovery " +
"source. An already tracked path fails rather than being " +
"implicitly updated."
UntrackLong = "Remove the declarations from lucy.yaml. Working content " +
"and historical backups are preserved."
UpdateLong = "Snapshot and accept current bytes for one or more tracked " +
"files, or accept every tracked file at once with --all. The " +
"change policy and recover_url stay unchanged. --from-source " +
"fetches fresh bytes from recover_url, preserves the displaced " +
"working bytes, and accepts the new digest without requiring a " +
"match on the old one."
RecoverLong = "Restore one or more tracked files to their accepted " +
"digests without accepting a new revision. Local content-addressed blobs are " +
"verified first; when none is available the recover_url is " +
"fetched directly and checked against the accepted digest. " +
"Displaced working bytes are preserved without being accepted. " +
"--all restores every entry with an accepted hash and reports " +
"presence-only entries as skipped."
RevertLong = "Restore and accept a backed-up historical revision of a " +
"tracked file. Without a target the accepted revision's parent is " +
"followed, so repeated reverts navigate backward instead of " +
"toggling. A hash selects a digest, --revision selects one exact " +
"historical sequence."
LockLong = "Snapshot and accept the current bytes of one or more files, " +
"then warn about any future deviation without restoring or " +
"replacing them."
UnlockLong = "Accept the existing hashes and recover_urls but stop " +
"warning about deviation."
ListLong = "Read-only inspection of every tracked file. Mutable files " +
"may deviate without warning; immutable files warn during " +
"installation."
HistoryLong = "List file tracking events in chronological order with " +
"their sequence number, timestamp, digest, and operation. An " +
"optional path restricts the listing to one file, including one " +
"that is no longer tracked. No network access happens."
)
// NewCommand returns the file command tree. Output flags are inherited from
// the root command and are never registered again here.
func NewCommand(
contracts *cli.JSONContracts,
inv *cli.Invocation,
deps *cli.Dependencies,
) *cobra.Command {
trackCmd := newTrackCmd(inv, deps)
untrackCmd := newUntrackCmd(inv, deps)
updateCmd := newUpdateCmd(inv, deps)
recoverCmd := newRecoverCmd(inv, deps)
revertCmd := newRevertCmd(inv, deps)
lockCmd := newLockCmd(inv, deps)
unlockCmd := newUnlockCmd(inv, deps)
listCmd := newListCmd(inv, deps)
historyCmd := newHistoryCmd(inv, deps)
fileResultJSON.Bind(contracts, trackCmd)
fileResultJSON.Bind(contracts, untrackCmd)
fileResultJSON.Bind(contracts, updateCmd)
fileResultJSON.Bind(contracts, recoverCmd)
fileResultJSON.Bind(contracts, revertCmd)
fileResultJSON.Bind(contracts, lockCmd)
fileResultJSON.Bind(contracts, unlockCmd)
listJSON.Bind(contracts, listCmd)
historyJSON.Bind(contracts, historyCmd)
fileCmd := &cobra.Command{
Use: "file <subcommand>",
Short: FileShort,
Long: FileLong,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
cli.MarkHelpOnly(fileCmd)
fileCmd.AddCommand(
trackCmd,
untrackCmd,
updateCmd,
recoverCmd,
revertCmd,
lockCmd,
unlockCmd,
listCmd,
historyCmd,
)
return fileCmd
}
package file
import (
"fmt"
"io"
"time"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/internal/cli"
"github.com/spf13/cobra"
)
func newHistoryCmd(
inv *cli.Invocation,
_ *cli.Dependencies,
) *cobra.Command {
return &cobra.Command{
Use: "history [path]",
Short: HistoryShort,
Long: HistoryLong,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return actionFileHistory(cmd, inv, args)
},
}
}
var historyJSON cli.JSONResult[[]filetrack.Event]
func actionFileHistory(
cmd *cobra.Command,
inv *cli.Invocation,
args []string,
) error {
path := ""
if len(args) == 1 {
path = args[0]
}
events, err := filetrack.History(inv.Directory, path)
if err != nil {
return err
}
if events == nil {
events = []filetrack.Event{}
}
output := cli.NewQueryOutput(cmd, inv)
if output.JSON() {
return historyJSON.Write(output, events)
}
out := output.Human()
if len(events) == 0 {
fmt.Fprintln(out, "No file tracking history.")
return nil
}
for _, event := range events {
fmt.Fprintf(
out,
"#%d %s %s\n",
event.Sequence,
event.Time.UTC().Format(time.RFC3339),
event.Operation,
)
for _, change := range event.Changes {
writeHistoryChange(out, change)
}
}
return nil
}
func writeHistoryChange(out io.Writer, change filetrack.Change) {
if change.Accepted && change.After != nil && change.After.Hash != "" {
fmt.Fprintf(
out,
" %s accepted %s as revision %d\n",
change.Path,
change.After.Hash,
change.Revision,
)
} else if change.Revision != 0 && change.After != nil {
fmt.Fprintf(out, " %s active revision %d: %s\n", change.Path, change.Revision, change.After.Hash)
}
if change.BackupHash != "" {
fmt.Fprintf(
out,
" %s working backup %s\n",
change.Path,
change.BackupHash,
)
} else if change.Revision == 0 {
fmt.Fprintf(out, " %s\n", change.Path)
}
}
package file
import (
"fmt"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/internal/cli"
"github.com/spf13/cobra"
)
func newListCmd(
inv *cli.Invocation,
deps *cli.Dependencies,
) *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: ListShort,
Long: ListLong,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionFileList(cmd, inv, deps, args)
},
}
}
type fileListDocument struct {
Statuses []filetrack.Status `json:"statuses"`
Warnings []string `json:"warnings"`
}
var listJSON cli.JSONResult[fileListDocument]
func actionFileList(
cmd *cobra.Command,
inv *cli.Invocation,
_ *cli.Dependencies,
_ []string,
) error {
files, err := filetrack.DeclaredFiles(inv.Directory)
if err != nil {
return err
}
statuses, err := filetrack.Inspect(inv.Directory, files)
if err != nil {
return err
}
if statuses == nil {
statuses = []filetrack.Status{}
}
output := cli.NewQueryOutput(cmd, inv)
for _, status := range statuses {
if status.Immutable && status.State == "modified" {
output.Warn(fmt.Errorf("%s: immutable contents differ; use lucy file update to accept them or lucy file recover to restore the accepted revision", status.Path))
}
}
if output.JSON() {
return listJSON.Write(output, fileListDocument{
Statuses: statuses,
Warnings: output.Warnings(),
})
}
out := output.Human()
if len(statuses) == 0 {
fmt.Fprintln(out, "No tracked files.")
return nil
}
pathWidth, stateWidth := 0, 0
for _, status := range statuses {
pathWidth = max(pathWidth, len(status.Path))
stateWidth = max(stateWidth, len(status.State))
}
for _, status := range statuses {
fmt.Fprintf(
out,
"%-*s %-9s %-*s %s\n",
pathWidth,
status.Path,
statusPolicy(status.Immutable),
stateWidth,
status.State,
statusDetail(status),
)
}
return nil
}
func statusPolicy(immutable bool) string {
if immutable {
return "immutable"
}
return "mutable"
}
// statusDetail reports the accepted hash behind a state, plus the observed
// hash when it differs and the reason when the state could not be observed.
func statusDetail(status filetrack.Status) string {
if status.Error != "" {
return status.Error
}
if status.Hash == "" {
return "no accepted hash"
}
if status.ActualHash == "" || status.ActualHash == status.Hash {
return status.Hash
}
return status.Hash + " (actual " + status.ActualHash + ")"
}
package file
import (
"fmt"
"io"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/terminal/style"
"github.com/spf13/cobra"
)
type fileResultView struct {
Operation filetrack.Operation `json:"operation"`
Changes []filetrack.Change `json:"changes,omitempty"`
Skipped []string `json:"skipped,omitempty"`
Reclaimed filetrack.Reclaimed `json:"reclaimed,omitzero"`
}
var fileResultJSON cli.JSONResult[fileResultView]
func newFileResultView(result filetrack.Result) fileResultView {
return fileResultView{
Operation: result.Event.Operation,
Changes: result.Event.Changes,
Skipped: result.Skipped,
Reclaimed: result.Reclaimed,
}
}
var resultVerbs = map[filetrack.Operation]string{
filetrack.Track: "tracked",
filetrack.Untrack: "untracked",
filetrack.Update: "updated",
filetrack.Recover: "recovered",
filetrack.Revert: "reverted",
filetrack.Lock: "locked",
filetrack.Unlock: "unlocked",
}
func reportFileResult(
cmd *cobra.Command,
inv *cli.Invocation,
result filetrack.Result,
) error {
output := cli.NewOperationOutput(cmd, inv)
if output.JSON() {
return fileResultJSON.Write(output, newFileResultView(result))
}
out := output.Human()
for _, change := range result.Event.Changes {
writeChangeLine(out, result.Event.Operation, change)
}
for _, skipped := range result.Skipped {
fmt.Fprintf(out, "skipped %s\n", skipped)
}
if result.Reclaimed.Objects > 0 {
noun := "objects"
if result.Reclaimed.Objects == 1 {
noun = "object"
}
fmt.Fprintf(
out,
"reclaimed %d %s (%s)\n",
result.Reclaimed.Objects,
noun,
style.FormatBytesBinary(result.Reclaimed.Bytes),
)
}
return nil
}
func writeChangeLine(
out io.Writer,
operation filetrack.Operation,
change filetrack.Change,
) {
fmt.Fprintf(out, "%s %s\n", resultVerbs[operation], change.Path)
switch {
case change.Accepted && change.After != nil && change.After.Hash != "":
fmt.Fprintf(out, " accepted %s as revision %d\n", change.After.Hash, change.Revision)
case change.Revision != 0 && change.After != nil && change.After.Hash != "":
fmt.Fprintf(out, " active revision %d: %s\n", change.Revision, change.After.Hash)
}
if displacedBytes(change) {
fmt.Fprintf(out, " working backup %s\n", change.BackupHash)
}
}
// displacedBytes reports whether the working backup holds bytes other than the
// ones the change published. Restoring unchanged content keeps a backup of
// itself, which is not news the user asked for.
func displacedBytes(change filetrack.Change) bool {
if change.BackupHash == "" {
return false
}
return change.After == nil || change.BackupHash != change.After.Hash
}
// pathOrAllArgs enforces the selection shared by update and recover: --all
// replaces every positional path instead of combining with them.
func pathOrAllArgs(cmd *cobra.Command, args []string) error {
all, _ := cmd.Flags().GetBool(flagAll)
if all {
if len(args) > 0 {
return fmt.Errorf("--%s conflicts with positional paths", flagAll)
}
return nil
}
if len(args) == 0 {
return fmt.Errorf("requires a path argument or --%s", flagAll)
}
return nil
}
// revertArgs enforces revert's optional targets: a path may carry one hash,
// and --revision selects a sequence instead of a recurrent digest.
func revertArgs(cmd *cobra.Command, args []string) error {
if len(args) < 1 || len(args) > 2 {
return fmt.Errorf("requires a path and an optional hash argument")
}
if len(args) == 2 && cmd.Flags().Changed(flagRevision) {
return fmt.Errorf("--%s conflicts with the hash argument", flagRevision)
}
return nil
}
package file
import (
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/internal/cli"
"github.com/spf13/cobra"
)
func newTrackCmd(
inv *cli.Invocation,
deps *cli.Dependencies,
) *cobra.Command {
cmd := &cobra.Command{
Use: "track <path>...",
Short: TrackShort,
Long: TrackLong,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return actionFileTrack(cmd, inv, deps, args)
},
}
cmd.Flags().Bool(
flagImmutable,
false,
"Warn about any change to the tracked contents",
)
cmd.Flags().Bool(
flagNoHash,
false,
"Track presence only, without an accepted hash",
)
cmd.Flags().String(
flagRecoverURL,
"",
"Record an explicit recovery source URL for the file",
)
return cmd
}
func newUntrackCmd(
inv *cli.Invocation,
deps *cli.Dependencies,
) *cobra.Command {
return &cobra.Command{
Use: "untrack <path>...",
Short: UntrackShort,
Long: UntrackLong,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return actionFileUntrack(cmd, inv, deps, args)
},
}
}
func newLockCmd(
inv *cli.Invocation,
deps *cli.Dependencies,
) *cobra.Command {
return &cobra.Command{
Use: "lock <path>...",
Short: LockShort,
Long: LockLong,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return actionFileLock(cmd, inv, deps, args)
},
}
}
func newUnlockCmd(
inv *cli.Invocation,
deps *cli.Dependencies,
) *cobra.Command {
return &cobra.Command{
Use: "unlock <path>...",
Short: UnlockShort,
Long: UnlockLong,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return actionFileUnlock(cmd, inv, deps, args)
},
}
}
func actionFileTrack(
cmd *cobra.Command,
inv *cli.Invocation,
deps *cli.Dependencies,
args []string,
) error {
immutable, _ := cmd.Flags().GetBool(flagImmutable)
noHash, _ := cmd.Flags().GetBool(flagNoHash)
recoverURL, _ := cmd.Flags().GetString(flagRecoverURL)
result, err := deps.Files.Run(
cmd.Context(),
inv.Directory,
filetrack.Request{
Operation: filetrack.Track,
Paths: args,
Immutable: immutable,
NoHash: noHash,
RecoverURL: recoverURL,
},
)
if err != nil {
return err
}
return reportFileResult(cmd, inv, result)
}
func actionFileUntrack(
cmd *cobra.Command,
inv *cli.Invocation,
deps *cli.Dependencies,
args []string,
) error {
result, err := deps.Files.Run(
cmd.Context(),
inv.Directory,
filetrack.Request{
Operation: filetrack.Untrack,
Paths: args,
},
)
if err != nil {
return err
}
return reportFileResult(cmd, inv, result)
}
func actionFileLock(
cmd *cobra.Command,
inv *cli.Invocation,
deps *cli.Dependencies,
args []string,
) error {
result, err := deps.Files.Run(
cmd.Context(),
inv.Directory,
filetrack.Request{
Operation: filetrack.Lock,
Paths: args,
},
)
if err != nil {
return err
}
return reportFileResult(cmd, inv, result)
}
func actionFileUnlock(
cmd *cobra.Command,
inv *cli.Invocation,
deps *cli.Dependencies,
args []string,
) error {
result, err := deps.Files.Run(
cmd.Context(),
inv.Directory,
filetrack.Request{
Operation: filetrack.Unlock,
Paths: args,
},
)
if err != nil {
return err
}
return reportFileResult(cmd, inv, result)
}
package file
import (
"fmt"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/internal/cli"
"github.com/spf13/cobra"
)
func newUpdateCmd(
inv *cli.Invocation,
deps *cli.Dependencies,
) *cobra.Command {
cmd := &cobra.Command{
Use: "update [path...]",
Short: UpdateShort,
Long: UpdateLong,
Args: pathOrAllArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionFileUpdate(cmd, inv, deps, args)
},
}
cmd.Flags().Bool(
flagFromSource,
false,
"Fetch fresh bytes from recover_url instead of the working file",
)
cmd.Flags().Bool(
flagAll,
false,
"Update every tracked file atomically",
)
return cmd
}
func newRecoverCmd(
inv *cli.Invocation,
deps *cli.Dependencies,
) *cobra.Command {
cmd := &cobra.Command{
Use: "recover [path...]",
Short: RecoverShort,
Long: RecoverLong,
Args: pathOrAllArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionFileRecover(cmd, inv, deps, args)
},
}
cmd.Flags().Bool(
flagAll,
false,
"Restore every tracked file with an accepted hash atomically",
)
return cmd
}
func newRevertCmd(
inv *cli.Invocation,
deps *cli.Dependencies,
) *cobra.Command {
cmd := &cobra.Command{
Use: "revert <path> [hash]",
Short: RevertShort,
Long: RevertLong,
Args: revertArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return actionFileRevert(cmd, inv, deps, args)
},
}
cmd.Flags().Uint64(
flagRevision,
0,
"Restore one exact historical revision sequence",
)
return cmd
}
func actionFileUpdate(
cmd *cobra.Command,
inv *cli.Invocation,
deps *cli.Dependencies,
args []string,
) error {
fromSource, _ := cmd.Flags().GetBool(flagFromSource)
all, _ := cmd.Flags().GetBool(flagAll)
result, err := deps.Files.Run(
cmd.Context(),
inv.Directory,
filetrack.Request{
Operation: filetrack.Update,
Paths: args,
All: all,
FromSource: fromSource,
},
)
if err != nil {
return err
}
return reportFileResult(cmd, inv, result)
}
func actionFileRecover(
cmd *cobra.Command,
inv *cli.Invocation,
deps *cli.Dependencies,
args []string,
) error {
all, _ := cmd.Flags().GetBool(flagAll)
result, err := deps.Files.Run(
cmd.Context(),
inv.Directory,
filetrack.Request{
Operation: filetrack.Recover,
Paths: args,
All: all,
},
)
if err != nil {
return err
}
return reportFileResult(cmd, inv, result)
}
func actionFileRevert(
cmd *cobra.Command,
inv *cli.Invocation,
deps *cli.Dependencies,
args []string,
) error {
hash := ""
if len(args) == 2 {
hash = args[1]
}
revision, _ := cmd.Flags().GetUint64(flagRevision)
if cmd.Flags().Changed(flagRevision) && revision == 0 {
return fmt.Errorf("--revision requires a positive history sequence")
}
result, err := deps.Files.Run(
cmd.Context(),
inv.Directory,
filetrack.Request{
Operation: filetrack.Revert,
Paths: args[:1],
Hash: hash,
Revision: revision,
},
)
if err != nil {
return err
}
return reportFileResult(cmd, inv, result)
}
package cli
import (
"strings"
"github.com/spf13/cobra"
)
// Output style flag names. The whole family is registered once as root
// persistent flags and applies to every subcommand.
const (
FlagJSON = "json"
FlagJSONCompact = "json-compact"
FlagLong = "long"
FlagNoColor = "no-color"
FlagNoInteractive = "no-interactive"
FlagNoStyle = "no-style"
FlagLogo = "logo"
FlagLogFile = "log-file"
FlagPrintLogs = "print-logs"
FlagLogLevel = "log-level"
FlagEcosystem = "ecosystem"
)
// JSONRendering names the machine-readable rendering of a command result.
type JSONRendering int
const (
// JSONNone renders the human-facing view.
JSONNone JSONRendering = iota
// JSONPretty prints JSON indented with two spaces.
JSONPretty
// JSONCompact prints JSON on a single line.
JSONCompact
)
// JSONFlagsPresent reports whether the raw command line names a JSON flag.
// The parser is not involved, so this works before flag parsing fails.
func JSONFlagsPresent(args []string) bool {
for _, arg := range args {
if arg == "--" {
break
}
if arg == "--json" ||
strings.HasPrefix(arg, "--json=") ||
arg == "--json-compact" ||
strings.HasPrefix(arg, "--json-compact=") {
return true
}
}
return false
}
// JSONMode reports the JSON rendering a command's flags select.
// --json-compact wins over --json when both are set.
func JSONMode(cmd *cobra.Command) JSONRendering {
if compact, _ := cmd.Flags().GetBool(FlagJSONCompact); compact {
return JSONCompact
}
if pretty, _ := cmd.Flags().GetBool(FlagJSON); pretty {
return JSONPretty
}
return JSONNone
}
func AddLongFlag(cmd *cobra.Command) {
cmd.Flags().BoolP(FlagLong, "l", false, "Show hidden or collapsed output")
}
func AddLogoFlag(cmd *cobra.Command) {
cmd.Flags().String(
FlagLogo,
"small",
"Status ASCII logo: none, small, or large (large is opt-in)",
)
}
// AddEcosystemFlag adds an optional mod or plugin type to a command.
func AddEcosystemFlag(cmd *cobra.Command) {
cmd.Flags().String(FlagEcosystem, "", "Mod or plugin type (fabric, forge, neoforge, bukkit, paper, mcdr)")
}
package info
import (
"context"
"fmt"
"sort"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/report"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/routing"
"github.com/spf13/cobra"
)
func NewCommand(
contracts *cli.JSONContracts,
inv *cli.Invocation,
deps *cli.Dependencies,
) *cobra.Command {
infoCmd := &cobra.Command{
Use: "info <package|file>",
Short: help.InfoShort,
Long: help.InfoLong,
Args: cobra.ExactArgs(1),
ValidArgsFunction: func(
cmd *cobra.Command,
args []string,
toComplete string,
) ([]string, cobra.ShellCompDirective) {
if len(args) >= 1 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
candidates, _ := cli.CompletePackageIDSuggestions(
cmd.Context(),
inv.Directory,
toComplete,
)
return candidates, cobra.ShellCompDirectiveDefault
},
RunE: func(cmd *cobra.Command, args []string) error {
return actionInfo(cmd, inv, deps, args)
},
}
infoJSON.Bind(contracts, infoCmd)
cli.AddLongFlag(infoCmd)
return infoCmd
}
func actionInfo(
cmd *cobra.Command,
inv *cli.Invocation,
deps *cli.Dependencies,
args []string,
) error {
ctx, stop := cli.CommandContext(cmd)
defer stop()
output := cli.NewQueryOutput(cmd, inv)
long, _ := cmd.Flags().GetBool(cli.FlagLong)
root := inv.Directory
if err := ctx.Err(); err != nil {
return infoCancellation(err)
}
isArtifact, err := isArtifactPath(root, args[0])
if err != nil {
return err
}
if isArtifact {
return actionArtifactInfo(
ctx,
output,
inv,
deps.Router,
long,
root,
resolveInfoPath(root, args[0]),
)
}
request, err := input.Parse(args[0])
if err != nil {
return infoReferenceFailure(args[0], err)
}
request, err = resolveAgainstLock(root, request)
if err != nil {
return err
}
return actionProjectInfo(ctx, output, inv, deps.Router, long, root, request)
}
// resolveAgainstLock expands a bare package name into the explicit
// "provider:project" reference the current lock binds it to. An explicit
// provider is always honored, and the requested version survives the
// expansion.
func resolveAgainstLock(
root string,
request catalog.VersionedRef,
) (catalog.VersionedRef, error) {
if request.Provider != catalog.ProviderUnspecified {
return request, nil
}
reference, err := cli.ResolveLockReference(root, request.Name.String())
if err != nil {
return catalog.VersionedRef{}, infoAmbiguousReferenceFailure(
request.Name.String(),
err,
)
}
if reference == "" {
return request, nil
}
resolved, err := input.Parse(reference)
if err != nil {
return request, nil
}
request.Provider = resolved.Provider
request.Name = resolved.Name
return request, nil
}
type infoFetchResult struct {
info upstream.Info
providerErrors []routing.ProviderError
err error
}
func actionProjectInfo(
ctx context.Context,
output *cli.Output,
inv *cli.Invocation,
router *routing.Router,
long bool,
root string,
request catalog.VersionedRef,
) error {
providers, err := router.ResolveInfoProviders(
request.Ecosystem,
request.Provider,
)
if err != nil {
return infoProviderSelectionFailure(request, err)
}
info, providerErrors, fetchErr := router.GetInfoFirstSuccess(
ctx,
providers,
request.Ref,
)
result := infoFetchResult{info: info, providerErrors: providerErrors, err: fetchErr}
if result.err != nil {
if failure := infoOperationFailure(ctx, result.err); failure != nil {
return failure
}
return infoProviderFailure(request, result.providerErrors, result.err)
}
if failure := infoOperationFailure(ctx, ctx.Err()); failure != nil {
return failure
}
warnings := infoProviderWarnings(result.providerErrors)
observed := infoObserveWorkspace(root)
lock := infoReadLock(root)
resolvedVersion, versionWarnings, err := infoResolveProjectVersion(
ctx,
router,
request,
result.info.Ref.Provider,
observed,
lock,
)
if err != nil {
return err
}
warnings = append(warnings, versionWarnings...)
builds, buildWarnings, err := infoProjectBuilds(ctx, router, result.info.Ref)
if err != nil {
return err
}
warnings = append(warnings, buildWarnings...)
entry := infoProjectEntry(
result.info.Metadata,
result.info.Ref,
resolvedVersion,
)
entry.Builds = append(entry.Builds, builds...)
entry.buildCriteria = infoBuildCriteriaFromLock(lock)
entry.Installed = infoInstalledAt(
root,
request.Ref,
observed,
lock,
)
document := newInfoDocument(infoKindProject, "")
document.Entries = append(document.Entries, entry)
return infoWriteDocument(ctx, output, inv, document, warnings, long)
}
// infoProviderWarnings reports partial provider failures. A successful
// document still carries every partial failure; none of them are written to
// stderr directly.
func infoProviderWarnings(
providerErrors []routing.ProviderError,
) []string {
warnings := make([]string, 0, len(providerErrors)+1)
notFound := make([]string, 0, len(providerErrors))
for _, providerErr := range providerErrors {
if infoIsNotFound(providerErr.Err) {
notFound = append(notFound, providerErr.Provider.Title())
continue
}
warnings = append(warnings, fmt.Sprintf(
"information from %s failed: %v",
providerErr.Provider.Title(),
providerErr.Err,
))
}
sort.Strings(warnings)
sort.Strings(notFound)
if len(notFound) > 0 {
warnings = append(
warnings,
"not found on "+strings.Join(notFound, ", "),
)
}
return warnings
}
func infoProviderFailure(
request catalog.VersionedRef,
providerErrors []routing.ProviderError,
err error,
) error {
name := infoCleanLine(request.Name.String())
if name == "" {
name = "the requested project"
}
lines := make([]string, 0, len(providerErrors))
allNotFound := len(providerErrors) > 0
for _, providerErr := range providerErrors {
if !infoIsNotFound(providerErr.Err) {
allNotFound = false
}
lines = append(lines, infoCleanLine(fmt.Sprintf(
"%s: %v",
providerErr.Provider.Title(),
providerErr.Err,
)))
}
if len(lines) == 0 {
lines = append(lines, infoCleanLine(err.Error()))
}
sort.Strings(lines)
code := infoErrorProvider
title := "cannot retrieve information for " + name
hint := "Check the project reference, or pass provider:project to choose a source."
if allNotFound {
code = infoErrorNotFound
title = "project not found: " + name
hint = "Check the project name, or pass provider:project to choose a source."
}
return &report.Detail{
Title: title,
Lines: lines,
Hint: hint,
Code: code,
Cause: err,
}
}
func infoProviderSelectionFailure(
request catalog.VersionedRef,
err error,
) error {
requested := "automatic"
if infoProviderUsable(request.Provider) {
requested = request.Provider.String()
}
return &report.Detail{
Title: "cannot select an information source",
Lines: []string{
infoCleanLine(err.Error()),
"Requested provider: " + requested,
},
Hint: "Pass provider:project to choose a source, for example modrinth:project.",
Code: infoErrorProviderPick,
Cause: err,
}
}
func infoReferenceFailure(reference string, err error) error {
return &report.Detail{
Title: "cannot parse package reference",
Lines: []string{
infoCleanLine(reference),
infoCleanLine(err.Error()),
},
Hint: "Use provider:project@version, for example modrinth:fabric-api.",
Code: infoErrorReference,
Cause: err,
}
}
func infoAmbiguousReferenceFailure(name string, err error) error {
return &report.Detail{
Title: "cannot resolve " + infoCleanLine(name) + " in the current lock",
Lines: []string{infoCleanLine(err.Error())},
Hint: "Pass an explicit provider:project reference.",
Code: infoErrorAmbiguous,
Cause: err,
}
}
func infoIsNotFound(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "not found")
}
package info
import (
"context"
"fmt"
"sort"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/routing"
"github.com/mclucy/lucy/workspace"
)
// infoBuildCriteria describes the runtime a lock declares. An empty
// dimension is unknown and never filters builds.
type infoBuildCriteria struct {
Minecraft string
Ecosystems []catalog.Ecosystem
}
// infoProjectBuilds lists every published build the source can report. The
// project document survives a listing failure; the failure becomes a warning.
func infoProjectBuilds(
ctx context.Context,
router *routing.Router,
ref catalog.Ref,
) ([]infoBuild, []string, error) {
if !infoProviderUsable(ref.Provider) {
return nil, nil, nil
}
builds, err := router.Builds(ctx, ref)
if err != nil {
if failure := infoOperationFailure(ctx, err); failure != nil {
return nil, nil, failure
}
return nil, []string{fmt.Sprintf(
"could not list builds from %s: %v",
infoProviderLabel(ref.Provider),
err,
)}, nil
}
return infoBuildsFromUpstream(builds), nil, nil
}
func infoBuildsFromUpstream(builds []upstream.Build) []infoBuild {
converted := make([]infoBuild, 0, len(builds))
for _, build := range builds {
converted = append(converted, infoBuild{
Version: infoCleanLine(build.Version),
GameVersions: infoCleanList(build.GameVersions),
Loaders: infoCleanList(build.Loaders),
Channel: infoCleanLine(build.Channel),
Published: build.Published,
})
}
return converted
}
func infoCleanList(values []string) []string {
cleaned := make([]string, 0, len(values))
for _, value := range values {
if value = infoCleanLine(value); value != "" {
cleaned = append(cleaned, value)
}
}
return cleaned
}
// infoBuildCriteriaFromLock reads the declared server runtime only. Installed
// packages and observed activity never narrow the available build list.
func infoBuildCriteriaFromLock(lock *document.Lock) *infoBuildCriteria {
if lock == nil {
return nil
}
criteria := &infoBuildCriteria{
Minecraft: infoVersionText(lock.Server.Minecraft),
Ecosystems: infoLockNativeEcosystems(lock),
}
if criteria.Minecraft == "" && len(criteria.Ecosystems) == 0 {
return nil
}
return criteria
}
// infoLockNativeEcosystems reports every ecosystem the locked server product
// serves natively. A product that serves several ecosystems (for example
// Arclight) keeps all of them; filtering never narrows to one.
func infoLockNativeEcosystems(lock *document.Lock) []catalog.Ecosystem {
product := catalog.VersionedRef{
Name: catalog.Name(lock.Server.Product.String()),
Version: lock.Server.CoreVersion,
}
if match, ok := server.NormalizeProduct(product); ok &&
match.Ecosystem != catalog.EcosystemUnspecified {
product.Ecosystem = match.Ecosystem
}
components := make([]catalog.VersionedRef, 0)
for _, input := range lock.Server.Inputs {
for _, module := range input.Modules {
components = append(components, catalog.VersionedRef{
Name: catalog.Name(module.ID),
Version: module.Version,
Ecosystem: module.Ecosystem,
})
}
}
ecosystems := make([]catalog.Ecosystem, 0, 3)
seen := make(map[catalog.Ecosystem]struct{}, 3)
add := func(ecosystem catalog.Ecosystem) {
if ecosystem == catalog.EcosystemUnspecified {
return
}
if _, ok := seen[ecosystem]; ok {
return
}
seen[ecosystem] = struct{}{}
ecosystems = append(ecosystems, ecosystem)
}
for _, offer := range workspace.ProductServedEcosystems(product, components) {
if offer.Compatibility != workspace.CompatFull {
continue
}
add(offer.Ecosystem)
}
add(product.Ecosystem)
if lock.MCDR != nil {
add(catalog.EcosystemMCDR)
}
return ecosystems
}
func infoBuildCriteriaText(criteria *infoBuildCriteria) string {
if criteria == nil {
return ""
}
parts := make([]string, 0, len(criteria.Ecosystems)+1)
for _, ecosystem := range criteria.Ecosystems {
if ecosystem == catalog.EcosystemUnspecified {
continue
}
parts = append(parts, ecosystem.Title())
}
if criteria.Minecraft != "" {
parts = append(parts, "Minecraft "+criteria.Minecraft)
}
return strings.Join(parts, ", ")
}
// infoBuildCompatible keeps builds that are not known to be incompatible.
// Missing build metadata stays visible: a source that reports no game
// versions or loaders has not proven a mismatch.
func infoBuildCompatible(
build infoBuild,
criteria *infoBuildCriteria,
) bool {
if criteria == nil {
return true
}
if criteria.Minecraft != "" && len(build.GameVersions) > 0 {
matches := false
for _, gameVersion := range build.GameVersions {
if strings.EqualFold(
strings.TrimSpace(gameVersion),
criteria.Minecraft,
) {
matches = true
break
}
}
if !matches {
return false
}
}
return infoBuildMatchesEcosystems(build.Loaders, criteria.Ecosystems)
}
func infoBuildMatchesEcosystems(
loaders []string,
required []catalog.Ecosystem,
) bool {
if len(required) == 0 || len(loaders) == 0 {
return true
}
unknown := false
for _, loader := range loaders {
ecosystem, ok := infoBuildEcosystem(loader)
if !ok {
unknown = true
continue
}
for _, offer := range required {
if offer.Satisfy(ecosystem) {
return true
}
}
}
return unknown
}
// infoBuildEcosystem maps the loader spellings providers use onto the
// compatibility surfaces Lucy models. Unrecognized loaders stay unknown so
// the human view never claims a proven mismatch.
func infoBuildEcosystem(loader string) (catalog.Ecosystem, bool) {
switch strings.ToLower(strings.TrimSpace(loader)) {
case "fabric":
return catalog.EcosystemFabric, true
case "forge":
return catalog.EcosystemForge, true
case "neoforge":
return catalog.EcosystemNeoForge, true
case "paper", "purpur", "folia", "leaves":
return catalog.EcosystemPaper, true
case "bukkit", "spigot", "craftbukkit":
return catalog.EcosystemBukkit, true
case "sponge", "spongevanilla", "spongeforge", "spongeneo":
return catalog.EcosystemSponge, true
case "bungeecord", "waterfall":
return catalog.EcosystemBungeeCord, true
case "velocity":
return catalog.EcosystemVelocity, true
case "mcdr", "mcdreforged":
return catalog.EcosystemMCDR, true
default:
// "minecraft" usually marks a resource pack or data pack rather than
// a loader. It stays unknown so a server product is not reported as
// incompatible with content it can still serve.
return catalog.EcosystemUnspecified, false
}
}
// infoBuildsNewestFirst orders the bounded human view. Builds without a
// publication date stay last without inventing an order among themselves.
func infoBuildsNewestFirst(builds []infoBuild) []infoBuild {
sorted := append([]infoBuild(nil), builds...)
sort.SliceStable(sorted, func(left, right int) bool {
return sorted[left].Published.After(sorted[right].Published)
})
return sorted
}
package info
import (
"time"
"github.com/mclucy/lucy/internal/cli"
)
const (
infoDocumentSchema = 1
infoKindProject = "project"
infoKindArtifact = "artifact"
infoFormatMarkdown = "markdown"
infoFormatText = "text"
infoDistributionBundled = "bundled"
infoDistributionSeparate = "separate"
infoDistributionServer = "server"
infoLinkHomepage = "homepage"
infoLinkWiki = "wiki"
infoLinkIssues = "issues"
infoLinkSource = "source"
infoLinkDownload = "download"
infoLinkForum = "forum"
infoLinkFunding = "funding"
infoLinkOther = "other"
infoErrorReference = "info.invalid_reference"
infoErrorAmbiguous = "info.ambiguous_reference"
infoErrorProvider = "info.provider_failed"
infoErrorProviderPick = "info.provider_unavailable"
infoErrorNotFound = "info.not_found"
infoErrorWorkspace = "info.workspace_failed"
infoErrorWrite = "info.write_failed"
infoErrorArtifactMiss = "info.artifact_missing"
infoErrorArtifactRead = "info.artifact_unreadable"
infoErrorArtifactEmpty = "info.artifact_empty"
)
// infoDocument is the one wire format both project information and local
// archive inspection produce. Entries are never folded here; the human
// renderer bounds its own view.
type infoDocument struct {
Schema int `json:"schema"`
Kind string `json:"kind"`
File string `json:"file,omitempty"`
Entries []infoEntry `json:"entries"`
Warnings []string `json:"warnings"`
}
var infoJSON cli.JSONResult[infoDocument]
type infoEntry struct {
Title string `json:"title"`
Summary string `json:"summary"`
Ref string `json:"ref"`
Installed *infoInstalled `json:"installed,omitempty"`
Needs []infoDependency `json:"needs"`
Optional []infoDependency `json:"optional"`
Links []infoLink `json:"links"`
Description string `json:"description"`
DescriptionFormat string `json:"description_format"`
Authors []infoAuthor `json:"authors"`
License string `json:"license"`
Identity *infoIdentity `json:"identity,omitempty"`
Upstream *infoUpstream `json:"upstream,omitempty"`
Dependencies []infoDependency `json:"dependencies"`
DependenciesKnown bool `json:"dependencies_known"`
// Builds preserves every build the source reported, in source order.
Builds []infoBuild `json:"builds"`
// buildCriteria is presentation context, never document data.
buildCriteria *infoBuildCriteria
}
// infoBuild mirrors one published project version as reported upstream.
// Empty metadata means the source did not report it.
type infoBuild struct {
Version string `json:"version"`
GameVersions []string `json:"game_versions"`
Loaders []string `json:"loaders"`
Channel string `json:"channel"`
Published time.Time `json:"published,omitzero"`
}
type infoInstalled struct {
Ref string `json:"ref"`
Version string `json:"version"`
Path string `json:"path"`
}
type infoDependency struct {
Ref string `json:"ref"`
Constraint string `json:"constraint,omitempty"`
Distribution string `json:"distribution"`
Required bool `json:"required"`
Relationship string `json:"relationship,omitempty"`
}
type infoLink struct {
Label string `json:"label"`
Kind string `json:"kind"`
URL string `json:"url"`
}
type infoAuthor struct {
Name string `json:"name"`
Role string `json:"role,omitempty"`
URL string `json:"url,omitempty"`
}
type infoIdentity struct {
Name string `json:"name"`
Version string `json:"version"`
Provider string `json:"provider"`
Ecosystem string `json:"ecosystem"`
}
type infoUpstream struct {
Ref string `json:"ref"`
Version string `json:"version"`
}
func newInfoDocument(kind, file string) infoDocument {
return infoDocument{
Schema: infoDocumentSchema,
Kind: kind,
File: file,
Entries: []infoEntry{},
Warnings: []string{},
}
}
func newInfoEntry() infoEntry {
return infoEntry{
Needs: []infoDependency{},
Optional: []infoDependency{},
Links: []infoLink{},
Authors: []infoAuthor{},
Dependencies: []infoDependency{},
Builds: []infoBuild{},
}
}
package info
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/report"
"github.com/mclucy/lucy/terminal"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/routing"
)
func isArtifactPath(root, path string) (bool, error) {
if !artifact.SupportsPath(path) {
return false, nil
}
resolved := resolveInfoPath(root, path)
info, err := os.Stat(resolved)
if err != nil {
return true, infoArtifactPathFailure(root, path, err)
}
if !info.Mode().IsRegular() {
return true, &report.Detail{
Title: "artifact is not a regular file: " +
infoDisplayPath(root, path),
Lines: []string{"A directory or special file cannot be inspected."},
Hint: "Pass a packaged JAR, ZIP, PYZ, or MCDR file.",
Code: infoErrorArtifactRead,
}
}
return true, nil
}
func actionArtifactInfo(
ctx context.Context,
output *cli.Output,
inv *cli.Invocation,
router *routing.Router,
long bool,
root string,
filePath string,
) error {
if err := ctx.Err(); err != nil {
return infoCancellation(err)
}
infos, err := artifact.ReadInfo(filePath)
if err != nil {
return infoArtifactReadFailure(root, filePath, err)
}
if len(infos) == 0 {
return infoArtifactEmptyFailure(root, filePath)
}
document, warnings, err := buildArtifactDocument(
ctx,
router,
root,
filePath,
infos,
)
if err != nil {
return err
}
return infoWriteDocument(ctx, output, inv, document, warnings, long)
}
// artifactLookup is the upstream result for one artifact file. Ref is the
// project that matched the file hash, nil when nothing matched. Info is that
// project's metadata, nil when the metadata lookup failed. Warnings holds the
// problems found during the lookup.
type artifactLookup struct {
Ref *catalog.VersionedRef
Info *upstream.Info
Warnings []string
}
func (l artifactLookup) Matched() bool {
return l.Ref != nil
}
func lookupArtifactUpstream(
ctx context.Context,
router *routing.Router,
filePath string,
) (artifactLookup, error) {
result := artifactLookup{}
hashLookup := router.ResolveArtifactByHash(
ctx,
artifact.File{Path: filePath},
)
if err := ctx.Err(); err != nil {
return result, infoCancellation(err)
}
for _, providerErr := range hashLookup.Errors {
result.Warnings = append(result.Warnings, fmt.Sprintf(
"could not look up this artifact on %s: %v",
providerErr.Provider.Title(),
providerErr.Err,
))
}
if !hashLookup.Matched() {
if hashLookup.Complete() {
result.Warnings = append(
result.Warnings,
"no upstream project matched this artifact hash",
)
} else {
result.Warnings = append(
result.Warnings,
"could not determine whether this artifact exists upstream",
)
}
return result, nil
}
ref := hashLookup.Ref
result.Ref = &ref
providers, err := router.ResolveInfoProviders(ref.Ecosystem, ref.Provider)
if err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf(
"cannot retrieve upstream metadata for %s: %v",
ref.StringBase(),
err,
))
return result, nil
}
info, providerErrors, lookupErr := router.GetInfoFirstSuccess(
ctx,
providers,
ref.Ref,
)
if err := ctx.Err(); err != nil {
return result, infoCancellation(err)
}
for _, providerErr := range providerErrors {
result.Warnings = append(result.Warnings, fmt.Sprintf(
"could not retrieve metadata from %s: %v",
providerErr.Provider.Title(),
providerErr.Err,
))
}
if lookupErr != nil {
if failure := infoOperationFailure(ctx, lookupErr); failure != nil {
return result, failure
}
if len(providerErrors) == 0 {
result.Warnings = append(result.Warnings, fmt.Sprintf(
"cannot retrieve upstream metadata for %s: %v",
ref.StringBase(),
lookupErr,
))
}
return result, nil
}
result.Info = &info
return result, nil
}
func buildArtifactDocument(
ctx context.Context,
router *routing.Router,
root string,
filePath string,
infos []artifact.PackageInfo,
) (infoDocument, []string, error) {
lookup, err := lookupArtifactUpstream(ctx, router, filePath)
if err != nil {
return infoDocument{}, nil, err
}
document := newInfoDocument(
infoKindArtifact,
infoDisplayPath(root, filePath),
)
warnings := append([]string{}, lookup.Warnings...)
single := len(infos) == 1
attributed := 0
for _, info := range infos {
upstreamRef, match := infoArtifactUpstream(info, single, lookup)
if upstreamRef != nil {
attributed++
}
document.Entries = append(
document.Entries,
infoArtifactEntry(info, upstreamRef, match),
)
}
if lookup.Ref != nil && !single && attributed == 0 {
warnings = append(warnings, fmt.Sprintf(
"the upstream hash match %s does not name any package in this archive; upstream details were not applied",
lookup.Ref.String(),
))
}
return document, warnings, nil
}
func infoArtifactPathFailure(root, path string, err error) *report.Detail {
label := infoDisplayPath(root, path)
if errors.Is(err, os.ErrNotExist) {
return &report.Detail{
Title: "artifact not found: " + label,
Lines: []string{infoCleanLine(err.Error())},
Hint: "Check the path, or pass a package reference instead.",
Code: infoErrorArtifactMiss,
Cause: err,
}
}
return &report.Detail{
Title: "cannot inspect artifact: " + label,
Lines: []string{infoCleanLine(err.Error())},
Hint: "Check that the file is readable.",
Code: infoErrorArtifactRead,
Cause: err,
}
}
func infoArtifactReadFailure(root, path string, err error) *report.Detail {
return &report.Detail{
Title: "cannot read artifact: " + infoDisplayPath(root, path),
Lines: []string{infoCleanLine(err.Error())},
Hint: "Pass a readable JAR, ZIP, PYZ, or MCDR file.",
Code: infoErrorArtifactRead,
Cause: err,
}
}
func infoArtifactEmptyFailure(root, path string) *report.Detail {
return &report.Detail{
Title: "no package information in " + infoDisplayPath(root, path),
Lines: []string{
"The archive contains no descriptor Lucy can read.",
},
Hint: "Check that the file is a packaged mod or plugin.",
Code: infoErrorArtifactEmpty,
}
}
// infoDisplayPath keeps the document's file field and error labels stable
// without exposing an absolute workspace path.
func infoDisplayPath(root, path string) string {
return infoCleanPath(terminal.DisplayPath(root, resolveInfoPath(root, path)))
}
// resolveInfoPath anchors a user-supplied artifact path at the invocation
// directory; absolute paths pass through unchanged.
func resolveInfoPath(root, path string) string {
if filepath.IsAbs(path) {
return filepath.Clean(path)
}
return filepath.Join(root, path)
}
package info
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/x/ansi"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
)
// infoProjectEntry projects upstream project metadata. Version is the exact
// version info genuinely resolved, or Any when the request stayed unpinned.
func infoProjectEntry(
metadata catalog.Metadata,
ref catalog.Ref,
resolved version.Version,
) infoEntry {
entry := newInfoEntry()
entry.Title = infoTitle(metadata.Title, ref.Name.String())
entry.Summary = infoCleanLine(metadata.Brief)
entry.Ref = infoProjectRef(
ref.Name.String(),
ref.Provider,
resolved,
)
entry.License = infoCleanLine(metadata.License)
entry.Description, entry.DescriptionFormat = infoDescription(metadata)
entry.Authors = infoAuthors(metadata.Authors)
entry.Links = infoLinks(metadata)
return entry
}
// infoArtifactEntry projects one archive descriptor. Upstream metadata is
// merged only when the hash match is attributable to this descriptor.
func infoArtifactEntry(
local artifact.PackageInfo,
upstreamRef *catalog.VersionedRef,
match *upstream.Info,
) infoEntry {
entry := newInfoEntry()
metadata := local.Metadata
if match != nil {
metadata = infoMergeMetadata(metadata, match.Metadata)
}
fallbackName := local.Ref.Name.String()
if upstreamRef != nil {
fallbackName = infoFirstNonEmpty(
upstreamRef.Name.String(),
fallbackName,
)
}
entry.Title = infoTitle(metadata.Title, fallbackName)
entry.Summary = infoCleanLine(metadata.Brief)
if upstreamRef != nil {
entry.Ref = infoProjectRef(
upstreamRef.Name.String(),
upstreamRef.Provider,
upstreamRef.Version,
)
entry.Upstream = &infoUpstream{
Ref: infoProjectRef(
upstreamRef.Name.String(),
upstreamRef.Provider,
version.Any,
),
Version: infoVersionText(upstreamRef.Version),
}
} else {
entry.Ref = infoProjectRef(
local.Ref.Name.String(),
local.Ref.Provider,
local.Ref.Version,
)
}
entry.License = infoCleanLine(metadata.License)
entry.Description, entry.DescriptionFormat = infoDescription(metadata)
entry.Authors = infoAuthors(metadata.Authors)
entry.Links = infoLinks(metadata)
entry.Identity = infoArtifactIdentity(local)
entry.Needs, entry.Optional, entry.Dependencies = infoArtifactDependencies(local.Dependencies)
entry.DependenciesKnown = true
return entry
}
func infoArtifactIdentity(local artifact.PackageInfo) *infoIdentity {
ecosystem := ""
if local.Ref.Ecosystem != catalog.EcosystemUnspecified {
ecosystem = local.Ref.Ecosystem.String()
}
return &infoIdentity{
Name: infoCleanLine(local.Ref.Name.String()),
Version: infoVersionText(local.Ref.Version),
Provider: infoProviderText(local.Ref.Provider),
Ecosystem: ecosystem,
}
}
// infoArtifactUpstream reports the hash match worth applying to this
// descriptor. A single-descriptor archive is unambiguous even when the local
// module id differs from the upstream slug. A multi-descriptor archive needs
// the descriptor to name the matched project.
func infoArtifactUpstream(
local artifact.PackageInfo,
single bool,
lookup artifactLookup,
) (*catalog.VersionedRef, *upstream.Info) {
if lookup.Ref == nil || !lookup.Matched() {
return nil, nil
}
if single {
return lookup.Ref, lookup.Info
}
if infoNormalizedName(local.Ref.Name.String()) ==
infoNormalizedName(lookup.Ref.Name.String()) {
return lookup.Ref, lookup.Info
}
return nil, nil
}
// infoMergeMetadata lets upstream project content win while the archive fills
// the gaps. The description is one unit: never mix one document's text with
// another document's URL or format.
func infoMergeMetadata(
local catalog.Metadata,
remote catalog.Metadata,
) catalog.Metadata {
merged := catalog.Metadata{
Title: infoFirstNonEmpty(remote.Title, local.Title),
Brief: infoFirstNonEmpty(remote.Brief, local.Brief),
License: infoFirstNonEmpty(remote.License, local.License),
Authors: infoFirstNonEmptySlice(remote.Authors, local.Authors),
URLs: infoFirstNonEmptySlice(remote.URLs, local.URLs),
}
if remote.Description != "" || remote.DescriptionURL != "" {
merged.Description = remote.Description
merged.DescriptionURL = remote.DescriptionURL
merged.DescriptionIsMarkdown = remote.DescriptionIsMarkdown
} else {
merged.Description = local.Description
merged.DescriptionURL = local.DescriptionURL
merged.DescriptionIsMarkdown = local.DescriptionIsMarkdown
}
return merged
}
func infoDescription(metadata catalog.Metadata) (string, string) {
format := infoFormatText
if metadata.DescriptionIsMarkdown {
format = infoFormatMarkdown
}
return infoCleanText(metadata.Description), format
}
func infoTitle(title, fallback string) string {
if title = infoCleanLine(title); title != "" {
return title
}
return infoHumanTitle(fallback)
}
func infoHumanTitle(name string) string {
cleaned := infoCleanLine(
strings.NewReplacer("-", " ", "_", " ").Replace(name),
)
if cleaned == "" {
return ""
}
runes := []rune(cleaned)
runes[0] = []rune(strings.ToUpper(string(runes[0])))[0]
return string(runes)
}
func infoAuthors(persons []catalog.Person) []infoAuthor {
authors := make([]infoAuthor, 0, len(persons))
seen := make(map[string]struct{}, len(persons))
for _, person := range persons {
name := infoCleanLine(person.Name)
if name == "" {
continue
}
url := infoCleanLine(person.URL)
key := name + "\x00" + url
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
authors = append(authors, infoAuthor{
Name: name,
Role: infoCleanLine(person.Role),
URL: url,
})
}
return authors
}
var infoLinkRanks = map[string]int{
infoLinkHomepage: 0,
infoLinkSource: 1,
infoLinkIssues: 2,
infoLinkWiki: 3,
infoLinkDownload: 4,
infoLinkForum: 5,
infoLinkFunding: 6,
infoLinkOther: 7,
}
func infoLinks(metadata catalog.Metadata) []infoLink {
links := make([]infoLink, 0, len(metadata.URLs)+1)
for _, url := range metadata.URLs {
raw := infoCleanLine(url.URL)
if raw == "" {
continue
}
kind := infoLinkKind(url.Type)
label := infoCleanLine(url.Name)
if label == "" {
label = infoLinkLabel(kind)
}
links = append(links, infoLink{
Label: label,
Kind: kind,
URL: raw,
})
}
if strings.TrimSpace(metadata.Description) == "" {
if raw := infoCleanLine(metadata.DescriptionURL); raw != "" {
links = append(links, infoLink{
Label: "Full description",
Kind: infoLinkOther,
URL: raw,
})
}
}
sort.SliceStable(links, func(i, j int) bool {
left := infoLinkRanks[links[i].Kind]
right := infoLinkRanks[links[j].Kind]
return left < right
})
deduplicated := make([]infoLink, 0, len(links))
seen := make(map[string]struct{}, len(links))
for _, link := range links {
if _, exists := seen[link.URL]; exists {
continue
}
seen[link.URL] = struct{}{}
deduplicated = append(deduplicated, link)
}
return deduplicated
}
func infoLinkKind(urlType catalog.URLType) string {
switch urlType {
case catalog.URLHome:
return infoLinkHomepage
case catalog.URLSource:
return infoLinkSource
case catalog.URLIssues:
return infoLinkIssues
case catalog.URLWiki:
return infoLinkWiki
case catalog.URLFile:
return infoLinkDownload
case catalog.URLForum:
return infoLinkForum
case catalog.URLSponsor:
return infoLinkFunding
default:
return infoLinkOther
}
}
func infoLinkLabel(kind string) string {
switch kind {
case infoLinkHomepage:
return "Homepage"
case infoLinkSource:
return "Source"
case infoLinkIssues:
return "Issues"
case infoLinkWiki:
return "Wiki"
case infoLinkDownload:
return "Download"
case infoLinkForum:
return "Forum"
case infoLinkFunding:
return "Funding"
default:
return "Link"
}
}
func infoArtifactDependencies(
dependencies []artifact.Dependency,
) ([]infoDependency, []infoDependency, []infoDependency) {
all := make([]infoDependency, 0, len(dependencies))
needs := make([]infoDependency, 0, len(dependencies))
optional := make([]infoDependency, 0, len(dependencies))
for _, dependency := range dependencies {
ref := infoDependencyRef(dependency.Ref)
if ref == "" {
continue
}
constraint := dependency.Constraint
relationship := ""
switch dependency.Kind {
case catalog.DependencyKindIncompatible, catalog.DependencyKindConflict:
relationship = string(dependency.Kind)
constraint = constraint.Negate()
}
projected := infoDependency{
Ref: ref,
Constraint: infoConstraintText(constraint),
Distribution: infoDependencyDistribution(dependency.Origin),
Required: dependency.Mandatory,
Relationship: relationship,
}
all = append(all, projected)
if relationship != "" {
continue
}
switch projected.Distribution {
case infoDistributionBundled, infoDistributionServer:
continue
}
if projected.Required {
needs = append(needs, projected)
} else {
optional = append(optional, projected)
}
}
return needs, optional, all
}
func infoDependencyDistribution(
origin catalog.DependencyOrigin,
) string {
switch catalog.NormalizeDependencyOrigin(origin) {
case catalog.DependencyEmbedded:
return infoDistributionBundled
case catalog.DependencyServerProvided:
return infoDistributionServer
default:
return infoDistributionSeparate
}
}
// infoConstraintText renders a descriptor constraint as an actionable string.
// An unconstrained dependency keeps an empty constraint rather than claiming
// the literal selector "any".
func infoConstraintText(expr version.Expr) string {
if len(expr) == 0 {
return ""
}
alternatives := make([]string, 0, len(expr))
for _, conjunction := range expr {
terms := make([]string, 0, len(conjunction))
for _, term := range conjunction {
if term.Operator == version.OpNever {
terms = append(terms, "never")
continue
}
value, ok := term.Value.(fmt.Stringer)
if !ok || value.String() == "" {
continue
}
terms = append(terms, term.Operator.ToSign()+value.String())
}
if len(terms) > 0 {
alternatives = append(alternatives, strings.Join(terms, " and "))
}
}
return infoCleanLine(strings.Join(alternatives, " or "))
}
func infoDependencyRef(ref catalog.VersionedRef) string {
name := infoCleanLine(ref.Name.String())
if name == "" {
return ""
}
if infoProviderUsable(ref.Provider) {
return ref.Provider.String() + ":" + name
}
return name
}
func infoProjectRef(
name string,
provider catalog.Provider,
projectVersion version.Version,
) string {
name = infoCleanLine(name)
if name == "" {
return ""
}
ref := name
if infoProviderUsable(provider) {
ref = provider.String() + ":" + name
}
if resolved := infoVersionText(projectVersion); resolved != "" {
ref += "@" + resolved
}
return ref
}
// infoVersionText reports a concrete version. Selectors and invalid sentinels
// are not facts about a package and stay out of the document.
func infoVersionText(value version.Version) string {
if value == "" || value.CanInfer() || value.IsInvalid() {
return ""
}
return infoCleanLine(value.String())
}
func infoProviderUsable(provider catalog.Provider) bool {
return provider != catalog.ProviderUnspecified &&
provider != catalog.ProviderUnknown
}
func infoProviderText(provider catalog.Provider) string {
if !infoProviderUsable(provider) {
return ""
}
return provider.String()
}
func infoNormalizedName(name string) string {
return input.ToProjectName(name).String()
}
func infoFirstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func infoFirstNonEmptySlice[T any](slices ...[]T) []T {
for _, slice := range slices {
if len(slice) > 0 {
return slice
}
}
return nil
}
// infoCleanLine removes upstream terminal controls and collapses a value that
// belongs on one line.
func infoCleanLine(value string) string {
value = ansi.Strip(value)
var clean strings.Builder
clean.Grow(len(value))
for _, character := range value {
switch {
case character == '\n' || character == '\r' || character == '\t':
clean.WriteByte(' ')
case character < 0x20 || (character >= 0x7f && character <= 0x9f):
continue
default:
clean.WriteRune(character)
}
}
return strings.Join(strings.Fields(clean.String()), " ")
}
// infoCleanText keeps the structure of prose while removing terminal
// controls. The document carries the full description; the renderer bounds
// its own view.
func infoCleanText(value string) string {
value = strings.ReplaceAll(value, "\r\n", "\n")
value = strings.ReplaceAll(value, "\r", "\n")
value = ansi.Strip(value)
var clean strings.Builder
clean.Grow(len(value))
for _, character := range value {
switch {
case character == '\n' || character == '\t':
clean.WriteRune(character)
case character < 0x20 || (character >= 0x7f && character <= 0x9f):
continue
default:
clean.WriteRune(character)
}
}
return strings.TrimSpace(clean.String())
}
// infoCleanPath removes terminal controls from a local path without changing
// its meaningful whitespace.
func infoCleanPath(value string) string {
value = ansi.Strip(value)
var clean strings.Builder
clean.Grow(len(value))
for _, character := range value {
switch {
case character == '\n' || character == '\r' || character == '\t':
clean.WriteByte(' ')
case character < 0x20 || (character >= 0x7f && character <= 0x9f):
continue
default:
clean.WriteRune(character)
}
}
return strings.TrimSpace(clean.String())
}
package info
import (
"context"
"errors"
"fmt"
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/report"
)
// infoOperationFailure converts an interruption into the shared cancelled
// failure, including the cause that main maps to exit code 130.
func infoOperationFailure(ctx context.Context, err error) error {
if err == nil {
return nil
}
if ctx != nil && ctx.Err() != nil {
return infoCancellation(ctx.Err())
}
if errors.Is(err, context.Canceled) {
return infoCancellation(err)
}
return nil
}
func infoCancellation(cause error) *report.Detail {
if cause == nil {
cause = context.Canceled
}
return &report.Detail{
Title: "info interrupted",
Lines: []string{"The operation was cancelled."},
Code: cli.CodeOperationCancelled,
Cause: cause,
}
}
// infoNormalizeWarnings keeps the warning stream one line per entry and
// removes exact duplicates while preserving order.
func infoNormalizeWarnings(warnings []string) []string {
normalized := make([]string, 0, len(warnings))
seen := make(map[string]struct{}, len(warnings))
for _, warning := range warnings {
warning = infoCleanLine(warning)
if warning == "" {
continue
}
if _, exists := seen[warning]; exists {
continue
}
seen[warning] = struct{}{}
normalized = append(normalized, warning)
}
return normalized
}
func infoWriteDocument(
ctx context.Context,
output *cli.Output,
inv *cli.Invocation,
document infoDocument,
warnings []string,
long bool,
) error {
if err := ctx.Err(); err != nil {
return infoCancellation(err)
}
for _, warning := range infoNormalizeWarnings(warnings) {
output.Warn(errors.New(warning))
}
document.Warnings = output.Warnings()
if document.Entries == nil {
document.Entries = []infoEntry{}
}
if document.Warnings == nil {
document.Warnings = []string{}
}
if output.JSON() {
if err := infoJSON.Write(output, document); err != nil {
return infoWriteFailure(err)
}
return nil
}
rendered := renderInfoDocument(
document,
long,
inv.Directory,
infoRenderContext{theme: inv.Theme, terminal: inv.Stdout},
)
if rendered == "" {
return nil
}
if _, err := fmt.Fprintln(output.Human(), rendered); err != nil {
return infoWriteFailure(err)
}
return nil
}
func infoWriteFailure(err error) *report.Detail {
return &report.Detail{
Title: "cannot write info output",
Lines: []string{err.Error()},
Code: infoErrorWrite,
Cause: err,
}
}
package info
import (
"fmt"
"net/url"
"regexp"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/mclucy/lucy/terminal"
"github.com/mclucy/lucy/terminal/style"
)
// Markdown renderers emit OSC 8 links for arbitrary targets. Prose keeps its
// styled text, but hyperlinks stay exclusive to the vetted Links fact.
var infoRenderHyperlink = regexp.MustCompile(
"\x1b\\]8;[^\x07\x1b]*(?:\x07|\x1b\\\\)",
)
// infoRenderContext carries the presentation state of one info rendering.
type infoRenderContext struct {
theme style.Theme
terminal style.Terminal
}
const (
infoRenderDefaultWidth = 80
infoRenderFallbackHeight = 24
infoRenderDefaultLinks = 4
infoRenderDefaultDependencies = 5
infoRenderDefaultBuilds = 5
infoRenderLongBuilds = 10
infoRenderDescriptionMinLines = 3
infoRenderDescriptionMaxLines = 24
infoRenderEntryMinHeight = 8
infoRenderIndent = " "
infoRenderUnknownDependencies = "unknown (not reported by this source)"
infoRenderUnknownRequirements = "unknown (additional requirements may apply)"
)
// renderInfoDocument renders the human info view from the same document that
// produces JSON. Warnings stay in the document and reach the user through the
// command's warning stream, matching the status view.
func renderInfoDocument(
document infoDocument,
long bool,
root string,
presentation infoRenderContext,
) string {
width := presentation.terminal.Width
if width <= 0 {
width = infoRenderDefaultWidth
}
if len(document.Entries) == 0 {
return ""
}
height := presentation.terminal.Height
if !presentation.terminal.IsTTY || height <= 0 {
height = infoRenderFallbackHeight
}
share := max(infoRenderEntryMinHeight, height/len(document.Entries))
file := ""
if document.File != "" {
file = terminal.DisplayPath(
root,
strings.TrimSpace(infoRenderText(document.File)),
)
}
blocks := make([]string, 0, len(document.Entries))
for index, entry := range document.Entries {
entryFile := ""
if index == 0 {
entryFile = file
}
blocks = append(
blocks,
renderInfoEntry(
entry,
long,
width,
presentation.terminal.IsTTY,
share,
root,
entryFile,
presentation,
),
)
}
return infoRenderTrim(strings.Join(blocks, "\n\n"))
}
func renderInfoEntry(
entry infoEntry,
long bool,
width int,
tty bool,
height int,
root string,
file string,
presentation infoRenderContext,
) string {
title := infoRenderInline(entry.Title)
if title == "" {
title = infoRenderInline(entry.Ref)
}
if title == "" && entry.Identity != nil {
title = infoRenderInline(entry.Identity.Name)
}
if title == "" {
title = "unnamed"
}
titleLines := strings.Split(lipgloss.Wrap(title, width, ""), "\n")
for i := range titleLines {
titleLines[i] = presentation.theme.Accent(titleLines[i])
}
summary := infoRenderInline(entry.Summary)
var summaryLines []string
if summary != "" && summary != title {
summaryLines = strings.Split(lipgloss.Wrap(summary, width, ""), "\n")
}
facts := infoRenderEntryFacts(
entry,
long,
width,
tty,
root,
file,
presentation,
)
factWidth := width
if !tty {
keyWidth, valueWidth := 0, 0
for _, fact := range facts {
keyWidth = max(keyWidth, ansi.StringWidth(fact.Key))
for _, line := range strings.Split(fact.Value, "\n") {
valueWidth = max(valueWidth, ansi.StringWidth(line))
}
}
factWidth = max(factWidth, keyWidth+2+valueWidth)
}
factsText := terminal.RenderFacts(facts, factWidth, presentation.theme)
var factLines []string
if factsText != "" {
factLines = strings.Split(factsText, "\n")
}
lines := make([]string, 0, len(titleLines)+len(summaryLines)+len(factLines)+8)
lines = append(lines, titleLines...)
lines = append(lines, summaryLines...)
if len(factLines) > 0 {
lines = append(lines, "")
lines = append(lines, factLines...)
}
description := infoRenderDescription(
entry,
width,
long,
height-len(lines)-2,
presentation,
)
if description != "" {
lines = append(lines, "", presentation.theme.Accent("About"))
lines = append(lines, infoRenderIndentLines(description)...)
}
return strings.Join(lines, "\n")
}
func infoRenderEntryFacts(
entry infoEntry,
long bool,
width int,
tty bool,
root string,
file string,
presentation infoRenderContext,
) []terminal.Fact {
ref := infoRenderInline(entry.Ref)
license := ""
if long {
license = infoRenderInline(entry.License)
}
authors := ""
if long {
authors = infoRenderAuthors(entry.Authors, tty, presentation)
}
identity := ""
if long {
identity = infoRenderIdentity(entry.Identity)
}
upstream := ""
if long {
upstream = infoRenderUpstream(entry.Upstream)
}
builds := infoRenderBuilds(entry, long, presentation)
needs := infoRenderActionableDependencies(entry.Needs, long)
optional := infoRenderActionableDependencies(entry.Optional, long)
bundled := infoRenderDistributionDependencies(entry.Dependencies, "bundled", long)
server := infoRenderDistributionDependencies(entry.Dependencies, "server", long)
leftover := infoRenderLeftoverDependencies(entry, needs, optional, long)
links := infoRenderLinkCandidates(entry.Links, long)
keys := make([]string, 0, 13)
if file != "" {
keys = append(keys, "File")
}
if ref != "" {
keys = append(keys, "Ref")
}
if entry.Installed != nil {
keys = append(keys, "Installed")
}
if license != "" {
keys = append(keys, "License")
}
if authors != "" {
keys = append(keys, "Authors")
}
if identity != "" {
keys = append(keys, "Identity")
}
if upstream != "" {
keys = append(keys, "Upstream")
}
if builds != "" {
keys = append(keys, "Builds")
}
keys = append(keys, "Needs")
if len(optional) > 0 {
keys = append(keys, "Optional")
}
if len(bundled) > 0 {
keys = append(keys, "Bundled")
}
if len(server) > 0 {
keys = append(keys, "Server")
}
if len(leftover) > 0 {
keys = append(keys, "Dependencies")
}
if len(links) > 0 {
keys = append(keys, "Links")
}
valueWidth := infoRenderValueWidth(keys, width)
facts := make([]terminal.Fact, 0, len(keys))
if file != "" {
facts = append(facts, terminal.Fact{
Key: "File",
Value: ansi.Wrap(file, valueWidth, "/-_."),
})
}
if ref != "" {
facts = append(facts, terminal.Fact{
Key: "Ref",
Value: ansi.Wrap(ref, valueWidth, ":@-/"),
})
}
if entry.Installed != nil {
facts = append(facts, terminal.Fact{
Key: "Installed",
Value: infoRenderInstalled(
entry.Installed,
ref,
valueWidth,
root,
),
})
}
if license != "" {
facts = append(facts, terminal.Fact{Key: "License", Value: license})
}
if authors != "" {
facts = append(facts, terminal.Fact{Key: "Authors", Value: authors})
}
if identity != "" {
facts = append(facts, terminal.Fact{Key: "Identity", Value: identity})
}
if upstream != "" {
facts = append(facts, terminal.Fact{Key: "Upstream", Value: upstream})
}
if builds != "" {
facts = append(facts, terminal.Fact{Key: "Builds", Value: builds})
}
needsNote := ""
if !entry.DependenciesKnown {
needsNote = infoRenderUnknownDependencies
if len(needs) > 0 {
needsNote = infoRenderUnknownRequirements
}
}
needsValue := "none"
if len(needs) > 0 || needsNote != "" {
needsValue = infoRenderDependencyValue(needs, long, needsNote, presentation)
}
facts = append(facts, terminal.Fact{Key: "Needs", Value: needsValue})
if len(optional) > 0 {
facts = append(facts, terminal.Fact{
Key: "Optional",
Value: infoRenderDependencyValue(optional, long, "", presentation),
})
}
if len(bundled) > 0 {
facts = append(facts, terminal.Fact{
Key: "Bundled",
Value: infoRenderDependencyValue(bundled, true, "", presentation),
})
}
if len(server) > 0 {
facts = append(facts, terminal.Fact{
Key: "Server",
Value: infoRenderDependencyValue(server, true, "", presentation),
})
}
if len(leftover) > 0 {
facts = append(facts, terminal.Fact{
Key: "Dependencies",
Value: infoRenderDependencyValue(leftover, true, "", presentation),
})
}
if len(links) > 0 {
facts = append(facts, terminal.Fact{
Key: "Links",
Value: infoRenderLinks(
links,
long,
valueWidth,
tty,
presentation,
),
})
}
return facts
}
// infoRenderValueWidth mirrors RenderFacts' width arithmetic so values that
// must not break mid-token, such as paths and references, wrap beforehand.
func infoRenderValueWidth(keys []string, width int) int {
keyWidth := 0
for _, key := range keys {
keyWidth = max(keyWidth, ansi.StringWidth(key))
}
keyWidth = min(keyWidth, max(1, width/3))
return max(1, width-keyWidth-2)
}
func infoRenderInstalled(
installed *infoInstalled,
entryRef string,
valueWidth int,
root string,
) string {
version := infoRenderInline(installed.Version)
ref := infoRenderInline(installed.Ref)
if ref == entryRef {
ref = ""
}
head := version
if ref != "" {
if head == "" {
head = ref
} else {
head += " (" + ref + ")"
}
}
path := terminal.DisplayPath(
root,
strings.TrimSpace(infoRenderText(installed.Path)),
)
if path != "" {
path = ansi.Wrap(path, valueWidth, "/-_.")
}
switch {
case head != "" && path != "":
return head + "\n" + path
case head != "":
return head
case path != "":
return path
default:
return "yes"
}
}
func infoRenderIdentity(identity *infoIdentity) string {
if identity == nil {
return ""
}
name := infoRenderInline(identity.Name)
version := infoRenderInline(identity.Version)
head := strings.TrimSpace(name + " " + version)
details := make([]string, 0, 2)
if provider := infoRenderInline(identity.Provider); provider != "" {
details = append(details, provider)
}
if ecosystem := infoRenderInline(identity.Ecosystem); ecosystem != "" {
details = append(details, ecosystem)
}
if len(details) == 0 {
return head
}
if head == "" {
return strings.Join(details, ", ")
}
return head + " (" + strings.Join(details, ", ") + ")"
}
func infoRenderUpstream(upstream *infoUpstream) string {
if upstream == nil {
return ""
}
ref := infoRenderInline(upstream.Ref)
version := infoRenderInline(upstream.Version)
switch {
case ref != "" && version != "":
return ref + " (" + version + ")"
default:
return strings.TrimSpace(ref + " " + version)
}
}
func infoRenderAuthors(
authors []infoAuthor,
tty bool,
presentation infoRenderContext,
) string {
lines := make([]string, 0, len(authors))
for _, author := range authors {
name := infoRenderInline(author.Name)
if name == "" {
continue
}
line := name
if role := infoRenderInline(author.Role); role != "" {
line += " (" + role + ")"
}
if raw := infoRenderInline(author.URL); raw != "" {
link := raw
if safe, ok := infoRenderSafeURL(raw); ok && tty {
link = ansi.SetHyperlink(safe) +
presentation.theme.Link(raw) +
ansi.ResetHyperlink()
}
line += " " + link
}
lines = append(lines, line)
}
return strings.Join(lines, "\n")
}
// infoRenderActionableDependencies drops embedded and server-provided entries
// from the default actionable lists. Long output keeps them with full detail.
func infoRenderActionableDependencies(
dependencies []infoDependency,
long bool,
) []infoDependency {
visible := make([]infoDependency, 0, len(dependencies))
for _, dependency := range dependencies {
if infoRenderInline(dependency.Ref) == "" {
continue
}
if !long && infoRenderEmbeddedDependency(dependency) {
continue
}
visible = append(visible, dependency)
}
return visible
}
func infoRenderEmbeddedDependency(dependency infoDependency) bool {
switch strings.ToLower(infoRenderInline(dependency.Distribution)) {
case "bundled", "server":
return true
default:
return false
}
}
func infoRenderDistributionDependencies(
dependencies []infoDependency,
distribution string,
long bool,
) []infoDependency {
if !long {
return nil
}
matches := make([]infoDependency, 0, len(dependencies))
for _, dependency := range dependencies {
if infoRenderInline(dependency.Ref) == "" {
continue
}
if !strings.EqualFold(
infoRenderInline(dependency.Distribution),
distribution,
) {
continue
}
matches = append(matches, dependency)
}
return matches
}
// infoRenderLeftoverDependencies reports long-detail dependencies the
// actionable lists and distribution groups did not already cover.
func infoRenderLeftoverDependencies(
entry infoEntry,
needs []infoDependency,
optional []infoDependency,
long bool,
) []infoDependency {
if !long {
return nil
}
shown := make(map[string]struct{}, len(needs)+len(optional))
for _, dependency := range needs {
shown[infoRenderDependencyKey(dependency)] = struct{}{}
}
for _, dependency := range optional {
shown[infoRenderDependencyKey(dependency)] = struct{}{}
}
leftover := make([]infoDependency, 0, len(entry.Dependencies))
for _, dependency := range entry.Dependencies {
if infoRenderInline(dependency.Ref) == "" {
continue
}
if infoRenderEmbeddedDependency(dependency) {
continue
}
if _, ok := shown[infoRenderDependencyKey(dependency)]; ok {
continue
}
leftover = append(leftover, dependency)
}
return leftover
}
func infoRenderDependencyKey(dependency infoDependency) string {
return infoRenderInline(dependency.Ref) + "\x00" +
infoRenderInline(dependency.Constraint)
}
func infoRenderDependencyValue(
dependencies []infoDependency,
long bool,
note string,
presentation infoRenderContext,
) string {
lines := make([]string, 0, len(dependencies)+2)
for _, dependency := range dependencies {
lines = append(lines, infoRenderDependency(dependency, long))
}
hidden := 0
if !long && len(lines) > infoRenderDefaultDependencies {
hidden = len(lines) - infoRenderDefaultDependencies
lines = lines[:infoRenderDefaultDependencies]
}
if note != "" {
lines = append(lines, presentation.theme.Muted(note))
}
if hidden > 0 {
lines = append(lines, terminal.FoldHint(hidden, presentation.theme))
}
return strings.Join(lines, "\n")
}
// infoRenderBuilds bounds the human view, even with --long, and applies the
// lock's declared runtime. Builds whose source omitted compatibility metadata
// stay visible and say so.
func infoRenderBuilds(
entry infoEntry,
long bool,
presentation infoRenderContext,
) string {
if len(entry.Builds) == 0 {
return ""
}
compatible := make([]infoBuild, 0, len(entry.Builds))
for _, build := range entry.Builds {
if infoBuildCompatible(build, entry.buildCriteria) {
compatible = append(compatible, build)
}
}
filtered := len(entry.Builds) - len(compatible)
criteria := infoBuildCriteriaText(entry.buildCriteria)
if len(compatible) == 0 {
return presentation.theme.Muted("none compatible with " + criteria)
}
compatible = infoBuildsNewestFirst(compatible)
limit := infoRenderDefaultBuilds
if long {
limit = infoRenderLongBuilds
}
hidden := 0
if len(compatible) > limit {
hidden = len(compatible) - limit
compatible = compatible[:limit]
}
lines := make([]string, 0, len(compatible)+2)
for _, build := range compatible {
lines = append(lines, infoRenderBuildLine(build))
}
if filtered > 0 {
lines = append(
lines,
presentation.theme.Muted("filtered to "+criteria),
)
}
if hidden > 0 {
if long {
lines = append(lines, presentation.theme.Muted(fmt.Sprintf(
"… %d more builds, see --json for the complete list",
hidden,
)))
} else {
lines = append(lines, terminal.FoldHint(hidden, presentation.theme))
}
}
return strings.Join(lines, "\n")
}
func infoRenderBuildLine(build infoBuild) string {
version := infoRenderInline(build.Version)
if version == "" {
version = "unknown version"
}
notes := make([]string, 0, 4)
if channel := infoRenderInline(build.Channel); channel != "" {
notes = append(notes, channel)
}
if len(build.GameVersions) > 0 {
notes = append(notes, infoRenderInline(strings.Join(
build.GameVersions,
", ",
)))
} else {
notes = append(notes, "Minecraft version unknown")
}
if len(build.Loaders) > 0 {
notes = append(notes, infoRenderInline(strings.Join(
build.Loaders,
", ",
)))
} else {
notes = append(notes, "loaders unknown")
}
if build.Published.IsZero() {
notes = append(notes, "published unknown")
} else {
notes = append(notes, build.Published.Format("2006-01-02"))
}
return version + " (" + strings.Join(notes, ", ") + ")"
}
func infoRenderDependency(dependency infoDependency, long bool) string {
ref := infoRenderInline(dependency.Ref)
if ref == "" {
ref = "unknown"
}
if !long {
return ref
}
notes := make([]string, 0, 3)
if constraint := infoRenderInline(dependency.Constraint); constraint != "" {
notes = append(notes, constraint)
}
if distribution := infoRenderInline(dependency.Distribution); distribution != "" {
notes = append(notes, distribution)
}
if relationship := infoRenderInline(dependency.Relationship); relationship != "" {
notes = append(notes, relationship)
} else if !dependency.Required {
notes = append(notes, "optional")
}
if len(notes) == 0 {
return ref
}
return ref + " (" + strings.Join(notes, ", ") + ")"
}
func infoRenderLinkCandidates(links []infoLink, long bool) []infoLink {
candidates := make([]infoLink, 0, len(links))
seen := make(map[string]struct{}, len(links))
for _, link := range links {
raw := infoRenderInline(link.URL)
if raw == "" {
continue
}
if !long && strings.EqualFold(infoRenderInline(link.Kind), "funding") {
continue
}
if _, ok := seen[raw]; ok {
continue
}
seen[raw] = struct{}{}
candidates = append(candidates, link)
}
return candidates
}
func infoRenderLinks(
links []infoLink,
long bool,
valueWidth int,
tty bool,
presentation infoRenderContext,
) string {
visible := links
hidden := 0
if !long && len(visible) > infoRenderDefaultLinks {
hidden = len(visible) - infoRenderDefaultLinks
visible = visible[:infoRenderDefaultLinks]
}
lines := make([]string, 0, len(visible)+1)
for _, link := range visible {
lines = append(
lines,
infoRenderLinkLine(link, valueWidth, tty, presentation),
)
}
if hidden > 0 {
lines = append(lines, terminal.FoldHint(hidden, presentation.theme))
}
return strings.Join(lines, "\n")
}
func infoRenderLinkLine(
link infoLink,
width int,
tty bool,
presentation infoRenderContext,
) string {
label := infoRenderInline(link.Label)
raw := infoRenderInline(link.URL)
if label == raw {
label = ""
}
if raw == "" {
return label
}
target, safe := infoRenderSafeURL(raw)
display := raw
if tty {
budget := max(1, width)
if label != "" {
label = terminal.TruncateMiddle(
label,
max(1, min(ansi.StringWidth(label), max(1, width/3))),
)
budget = max(1, width-ansi.StringWidth(label)-2)
}
display = terminal.TruncateMiddle(display, budget)
if safe {
display = ansi.SetHyperlink(target) +
presentation.theme.Link(display) +
ansi.ResetHyperlink()
}
}
if label == "" {
return display
}
return label + " " + display
}
// infoRenderSafeURL accepts only printable HTTP(S) URLs for OSC 8 output.
func infoRenderSafeURL(raw string) (string, bool) {
if raw == "" || strings.ContainsAny(raw, " \t\r\n") {
return "", false
}
parsed, err := url.Parse(raw)
if err != nil {
return "", false
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https":
default:
return "", false
}
if parsed.Host == "" {
return "", false
}
return raw, true
}
func infoRenderDescription(
entry infoEntry,
width int,
long bool,
budget int,
presentation infoRenderContext,
) string {
description := strings.TrimSpace(infoRenderText(entry.Description))
if description == "" {
return ""
}
contentWidth := max(1, width-len(infoRenderIndent))
var rendered string
switch strings.ToLower(infoRenderInline(entry.DescriptionFormat)) {
case "markdown":
rendered = presentation.theme.Markdown(
description,
contentWidth,
presentation.terminal,
)
rendered = infoRenderHyperlink.ReplaceAllString(rendered, "")
default:
rendered = lipgloss.Wrap(description, contentWidth, "")
}
if rendered == "" {
return ""
}
if !long {
budget = min(
max(budget, infoRenderDescriptionMinLines),
infoRenderDescriptionMaxLines,
)
rendered = terminal.CenterElide(rendered, budget, presentation.theme)
}
return infoRenderTrim(rendered)
}
func infoRenderIndentLines(text string) []string {
lines := strings.Split(text, "\n")
for i := range lines {
if strings.TrimSpace(ansi.Strip(lines[i])) == "" {
lines[i] = ""
continue
}
lines[i] = infoRenderIndent + lines[i]
}
return lines
}
// infoRenderText removes upstream terminal controls while keeping the
// intentional style sequences this renderer adds afterwards.
func infoRenderText(value string) string {
value = ansi.Strip(value)
var clean strings.Builder
clean.Grow(len(value))
for _, character := range value {
switch {
case character == '\n':
clean.WriteRune(character)
case character == '\t':
clean.WriteString(" ")
case character < 0x20 || (character >= 0x7f && character <= 0x9f):
continue
default:
clean.WriteRune(character)
}
}
return clean.String()
}
func infoRenderInline(value string) string {
return strings.Join(strings.Fields(infoRenderText(value)), " ")
}
func infoRenderTrim(value string) string {
lines := strings.Split(value, "\n")
for i := range lines {
lines[i] = strings.TrimRight(lines[i], " \t\r")
}
for len(lines) > 0 &&
strings.TrimSpace(ansi.Strip(lines[len(lines)-1])) == "" {
lines = lines[:len(lines)-1]
}
for len(lines) > 0 &&
strings.TrimSpace(ansi.Strip(lines[0])) == "" {
lines = lines[1:]
}
return strings.Join(lines, "\n")
}
package info
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/terminal"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/routing"
"github.com/mclucy/lucy/version"
"github.com/mclucy/lucy/workspace"
"github.com/mclucy/lucy/workspace/discovery"
)
// infoObserveWorkspace is a read-only observation used for installed facts and
// for the version-resolution context. Failure to observe is not an info
// failure: the project document still renders without local facts.
func infoObserveWorkspace(root string) *workspace.Workspace {
if root == "" {
return nil
}
return workspace.Observe(
root,
discovery.Request{Policy: discovery.PolicyPreferModified},
)
}
// infoReadLock reports the workspace lock when one is readable. A missing or
// invalid lock only removes lock-derived matching; it never fails info.
func infoReadLock(root string) *document.Lock {
if root == "" {
return nil
}
lock, err := document.LockFile(root).Read()
if err != nil {
return nil
}
return lock
}
// infoInstalledAt reports an installed package only when the workspace
// contains the actual file. The lock may identify which file belongs to the
// project, but it never proves installation by itself.
func infoInstalledAt(
root string,
request catalog.Ref,
observed *workspace.Workspace,
lock *document.Lock,
) *infoInstalled {
if observed == nil || len(observed.Packages) == 0 {
return nil
}
locked := infoLockProjectPackages(lock, request)
matches := make([]workspace.DiscoveredPackage, 0, len(observed.Packages))
matchedLock := make([]*document.Package, 0, len(observed.Packages))
// A lock binding names the project; the recorded filename ties that
// binding to an observed file even when the descriptor uses another id.
for _, lockedPackage := range locked {
filename := filepath.Base(lockedPackage.Filename)
if filename == "" || filename == "." {
continue
}
for _, observedPackage := range observed.Packages {
if filepath.Base(observedPackage.Path) != filename {
continue
}
matches = append(matches, observedPackage)
matchedLock = append(matchedLock, lockedPackage)
}
}
if len(matches) == 0 {
var identityLock *document.Package
if len(locked) > 0 {
identityLock = locked[0]
}
for _, observedPackage := range observed.Packages {
if !infoInstalledNameMatches(request, observedPackage.ID) {
continue
}
matches = append(matches, observedPackage)
matchedLock = append(matchedLock, identityLock)
}
}
if len(matches) == 0 {
return nil
}
if len(matches) > 1 {
identityMatches := make([]workspace.DiscoveredPackage, 0, len(matches))
identityLocks := make([]*document.Package, 0, len(matches))
for index, match := range matches {
if !infoInstalledNameMatches(request, match.ID) {
continue
}
identityMatches = append(identityMatches, match)
identityLocks = append(identityLocks, matchedLock[index])
}
if len(identityMatches) > 0 {
matches = identityMatches
matchedLock = identityLocks
}
}
best := 0
for index := 1; index < len(matches); index++ {
if matches[index].Path < matches[best].Path {
best = index
}
}
installed := matches[best]
lockedPackage := matchedLock[best]
versionText := infoVersionText(installed.ID.Version)
for _, match := range matches {
if infoVersionText(match.ID.Version) != versionText {
versionText = ""
break
}
}
return &infoInstalled{
Ref: infoInstalledRef(installed, lockedPackage, request),
Version: versionText,
Path: infoCleanPath(terminal.DisplayPath(root, installed.Path)),
}
}
// infoInstalledRef qualifies the installed ref with the upstream project the
// lock or request matched. A local descriptor id may differ from that project
// slug, so it must not be paired with an inferred provider. Without upstream
// attribution the ref stays an honest, unqualified local identity.
func infoInstalledRef(
installed workspace.DiscoveredPackage,
lockedPackage *document.Package,
request catalog.Ref,
) string {
if lockedPackage != nil {
provider, name := infoLockPackageIdentity(lockedPackage, request)
if name != "" {
if !infoProviderUsable(provider) &&
infoProviderUsable(request.Provider) {
provider = request.Provider
}
return infoProjectRef(name, provider, version.Any)
}
}
if infoProviderUsable(request.Provider) {
return infoProjectRef(request.Name.String(), request.Provider, version.Any)
}
return infoProjectRef(
installed.ID.Name.String(),
catalog.ProviderUnspecified,
version.Any,
)
}
func infoLockProjectPackages(
lock *document.Lock,
request catalog.Ref,
) []*document.Package {
if lock == nil {
return nil
}
matches := make([]*document.Package, 0, len(lock.Packages))
for index := range lock.Packages {
lockedPackage := &lock.Packages[index]
if infoLockPackageMatches(lockedPackage, request) {
matches = append(matches, lockedPackage)
}
}
return matches
}
func infoLockPackageMatches(
lockedPackage *document.Package,
request catalog.Ref,
) bool {
for _, binding := range lockedPackage.Requirements {
provider, project, err := document.ParseReference(binding.Reference)
if err != nil {
continue
}
if infoProjectIdentityMatches(request, provider, project) {
return true
}
}
provider := lockedPackage.Provider
if !infoProviderUsable(provider) {
provider = request.Provider
}
if infoProviderUsable(request.Provider) &&
infoProviderUsable(provider) &&
request.Provider != provider {
return false
}
return infoNormalizedName(lockedPackage.ProjectID) ==
infoNormalizedName(request.Name.String())
}
func infoProjectIdentityMatches(
request catalog.Ref,
provider catalog.Provider,
project string,
) bool {
if infoProviderUsable(request.Provider) &&
infoProviderUsable(provider) &&
request.Provider != provider {
return false
}
return infoNormalizedName(project) ==
infoNormalizedName(request.Name.String())
}
func infoInstalledNameMatches(
request catalog.Ref,
identity catalog.VersionedRef,
) bool {
if infoNormalizedName(identity.Name.String()) !=
infoNormalizedName(request.Name.String()) {
return false
}
if !infoProviderUsable(request.Provider) {
return true
}
if !infoProviderUsable(identity.Provider) {
return true
}
return request.Provider == identity.Provider
}
func infoLockPackageProvider(
lockedPackage *document.Package,
) catalog.Provider {
if infoProviderUsable(lockedPackage.Provider) {
return lockedPackage.Provider
}
provider := catalog.ProviderUnspecified
for _, binding := range lockedPackage.Requirements {
candidate, _, err := document.ParseReference(binding.Reference)
if err != nil || !infoProviderUsable(candidate) {
continue
}
if provider != catalog.ProviderUnspecified && provider != candidate {
return catalog.ProviderUnspecified
}
provider = candidate
}
return provider
}
// infoLockPackageIdentity reports the project identity this lock package
// satisfies for the request. A binding that names the request wins over the
// package's opaque provider project id.
func infoLockPackageIdentity(
lockedPackage *document.Package,
request catalog.Ref,
) (catalog.Provider, string) {
for _, binding := range lockedPackage.Requirements {
provider, project, err := document.ParseReference(binding.Reference)
if err != nil {
continue
}
if infoProjectIdentityMatches(request, provider, project) {
return provider, project
}
}
return infoLockPackageProvider(lockedPackage), lockedPackage.ProjectID
}
// infoResolveProjectVersion turns an explicit version request into a concrete
// version only through the provider's context-aware catalogue. Anything less
// stays unpinned and produces a warning.
func infoResolveProjectVersion(
ctx context.Context,
router *routing.Router,
request catalog.VersionedRef,
provider catalog.Provider,
observed *workspace.Workspace,
lock *document.Lock,
) (version.Version, []string, error) {
if request.Version == "" || request.Version == version.Any {
return version.Any, nil, nil
}
registry, ok := infoCandidateRegistry(provider)
if !ok {
return version.Any, []string{fmt.Sprintf(
"%s has no version catalogue; version %s was not resolved, so the project ref stays unpinned",
infoProviderLabel(provider),
request.Version.String(),
)}, nil
}
minecraft := ""
if selection := infoRuntimeSelection(observed); selection != nil {
candidate := selection.MinecraftVersion()
if infoVersionText(candidate) != "" {
minecraft = candidate.String()
}
}
if minecraft == "" && lock != nil &&
infoVersionText(lock.Server.Minecraft) != "" {
minecraft = lock.Server.Minecraft.String()
}
loader := request.Ecosystem
if loader == catalog.EcosystemUnspecified {
loader = infoWorkspaceLoader(observed, lock)
}
if provider == catalog.ProviderMCDR {
loader = catalog.EcosystemMCDR
}
if provider != catalog.ProviderMCDR && minecraft == "" {
return version.Any, []string{fmt.Sprintf(
"no workspace Minecraft version is available to resolve version %s, so the project ref stays unpinned",
request.Version.String(),
)}, nil
}
projectName := request.Name.String()
if provider == catalog.ProviderMCDR {
projectName = request.Name.PythonName()
}
candidates, lookupErr := router.Candidates(
ctx,
registry,
projectName,
minecraft,
loader,
)
if lookupErr != nil {
if failure := infoOperationFailure(ctx, lookupErr); failure != nil {
return version.Any, nil, failure
}
return version.Any, []string{fmt.Sprintf(
"could not resolve version %s on %s: %v; the project ref stays unpinned",
request.Version.String(),
infoProviderLabel(provider),
lookupErr,
)}, nil
}
selected := infoSelectCandidate(candidates, request.Version)
if infoVersionText(selected) == "" {
return version.Any, []string{fmt.Sprintf(
"version %s was not found among the builds available for this workspace, so the project ref stays unpinned",
request.Version.String(),
)}, nil
}
return selected, nil, nil
}
func infoWorkspaceLoader(observed *workspace.Workspace, lock *document.Lock) catalog.Ecosystem {
if observed != nil {
if loader := infoLoaderFromOffers(observed.ServedEcosystems()); loader != catalog.EcosystemUnspecified {
return loader
}
}
if lock == nil {
return catalog.EcosystemUnspecified
}
components := make([]catalog.VersionedRef, 0)
for _, input := range lock.Server.Inputs {
for _, module := range input.Modules {
components = append(components, catalog.VersionedRef{
Name: catalog.Name(module.ID),
Version: module.Version,
Ecosystem: module.Ecosystem,
})
}
}
offers := workspace.ProductServedEcosystems(
catalog.VersionedRef{
Name: catalog.Name(lock.Server.Product.String()),
Version: lock.Server.CoreVersion,
},
components,
)
if loader := infoLoaderFromOffers(offers); loader != catalog.EcosystemUnspecified {
return loader
}
for _, module := range components {
if module.Ecosystem.IsModding() {
return module.Ecosystem
}
}
return catalog.EcosystemUnspecified
}
func infoLoaderFromOffers(offers []workspace.ServedEcosystem) catalog.Ecosystem {
for _, offer := range offers {
if offer.Compatibility == workspace.CompatFull &&
offer.Ecosystem != catalog.EcosystemMinecraft &&
offer.Ecosystem != catalog.EcosystemUnspecified {
return offer.Ecosystem
}
}
for _, offer := range offers {
if offer.Ecosystem != catalog.EcosystemMinecraft &&
offer.Ecosystem != catalog.EcosystemUnspecified {
return offer.Ecosystem
}
}
return catalog.EcosystemUnspecified
}
func infoCandidateRegistry(
provider catalog.Provider,
) (catalog.Registry, bool) {
switch provider {
case catalog.ProviderModrinth:
return catalog.RegistryModrinth, true
case catalog.ProviderCurseForge:
return catalog.RegistryCurseForge, true
case catalog.ProviderMCDR:
return catalog.RegistryMCDR, true
default:
return catalog.RegistryUnspecified, false
}
}
func infoRuntimeSelection(
observed *workspace.Workspace,
) *discovery.Selection {
if observed == nil {
return nil
}
selection := observed.Runtime()
if selection == nil || !selection.IsValid() {
return nil
}
return selection
}
func infoSelectCandidate(
candidates []upstream.Candidate,
requested version.Version,
) version.Version {
switch requested {
case version.Stable:
return infoNewestCandidate(
candidates,
func(channel upstream.ReleaseChannel) bool {
return channel == upstream.ReleaseChannelRelease
},
)
case version.Beta:
return infoNewestCandidate(
candidates,
func(upstream.ReleaseChannel) bool { return true },
)
case version.Version("latest"):
if selected := infoNewestCandidate(
candidates,
func(channel upstream.ReleaseChannel) bool {
return channel == upstream.ReleaseChannelRelease
},
); infoVersionText(selected) != "" {
return selected
}
return infoNewestCandidate(
candidates,
func(upstream.ReleaseChannel) bool { return true },
)
default:
requestedText := strings.TrimSpace(requested.String())
for _, candidate := range candidates {
if infoVersionText(candidate.Version) == "" {
continue
}
if strings.TrimSpace(candidate.Version.String()) == requestedText {
return candidate.Version
}
}
return ""
}
}
func infoNewestCandidate(
candidates []upstream.Candidate,
accept func(upstream.ReleaseChannel) bool,
) version.Version {
var newest *upstream.Candidate
for index := range candidates {
candidate := &candidates[index]
if infoVersionText(candidate.Version) == "" ||
!accept(candidate.Channel) {
continue
}
if newest == nil || candidate.PublishedAt.After(newest.PublishedAt) {
newest = candidate
}
}
if newest == nil {
return version.Any
}
return newest.Version
}
func infoProviderLabel(provider catalog.Provider) string {
if !infoProviderUsable(provider) {
return "the provider"
}
return provider.Title()
}
package search
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/internal/cli/help"
"github.com/mclucy/lucy/report"
"github.com/mclucy/lucy/terminal/style"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/routing"
"github.com/spf13/cobra"
)
const (
flagSortName = "sort"
flagIncludeClientName = "include-client"
flagEcosystemName = cli.FlagEcosystem
flagRegistryName = "registry"
)
func NewCommand(
contracts *cli.JSONContracts,
inv *cli.Invocation,
deps *cli.Dependencies,
) *cobra.Command {
searchCmd := &cobra.Command{
Use: "search",
Short: help.SearchShort,
Args: cobra.ExactArgs(1),
ValidArgsFunction: func(
cmd *cobra.Command,
args []string,
toComplete string,
) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
},
PreRunE: func(cmd *cobra.Command, args []string) error {
sort, _ := cmd.Flags().GetString(flagSortName)
if !Sort(sort).Valid() {
return errors.New("--sort must be one of \"relevance\", \"downloads\", \"newest\"")
}
ecosystem, _ := cmd.Flags().GetString(flagEcosystemName)
if _, err := searchEcosystem(ecosystem); err != nil {
return err
}
registry, _ := cmd.Flags().GetString(flagRegistryName)
_, err := searchRegistry(registry)
return err
},
RunE: func(cmd *cobra.Command, args []string) error {
return actionSearch(cmd, inv, deps, args)
},
}
searchJSON.Bind(contracts, searchCmd)
searchCmd.Flags().String(
flagSortName,
"relevance",
"Sort results by SORT (relevance, downloads, newest)",
)
searchCmd.Flags().BoolP(
flagIncludeClientName,
"c",
false,
"Also show client-only mods in results",
)
searchCmd.Flags().String(
flagEcosystemName,
"",
"Filter results by ecosystem (fabric, forge, neoforge, bukkit, paper)",
)
searchCmd.Flags().StringP(
flagRegistryName,
"s",
"",
"Restrict results to REGISTRY",
)
cli.AddLongFlag(searchCmd)
_ = searchCmd.RegisterFlagCompletionFunc(
flagRegistryName,
func(cmd *cobra.Command, args []string, toComplete string) (
[]string,
cobra.ShellCompDirective,
) {
candidates := cli.FilterByPrefix(
cli.StaticSearchRegistryCandidates(),
toComplete,
)
return cli.ToCobraCompletions(candidates), cobra.ShellCompDirectiveNoFileComp
},
)
_ = searchCmd.RegisterFlagCompletionFunc(
flagSortName,
func(cmd *cobra.Command, args []string, toComplete string) (
[]string,
cobra.ShellCompDirective,
) {
candidates := cli.FilterByPrefix(StaticSortCandidates(), toComplete)
return cli.ToCobraCompletions(candidates), cobra.ShellCompDirectiveNoFileComp
},
)
_ = searchCmd.RegisterFlagCompletionFunc(
flagEcosystemName,
func(cmd *cobra.Command, args []string, toComplete string) (
[]string,
cobra.ShellCompDirective,
) {
candidates := cli.FilterByPrefix(
cli.StaticSearchEcosystemCandidates(),
toComplete,
)
return cli.ToCobraCompletions(candidates), cobra.ShellCompDirectiveNoFileComp
},
)
return searchCmd
}
func actionSearch(
cmd *cobra.Command,
inv *cli.Invocation,
deps *cli.Dependencies,
args []string,
) error {
ctx, stop := cli.CommandContext(cmd)
defer stop()
sortOrder, _ := cmd.Flags().GetString(flagSortName)
client, _ := cmd.Flags().GetBool(flagIncludeClientName)
long, _ := cmd.Flags().GetBool(cli.FlagLong)
ecosystemArg, _ := cmd.Flags().GetString(flagEcosystemName)
resolvedEcosystem, err := searchEcosystem(ecosystemArg)
if err != nil {
return err
}
registryArg, _ := cmd.Flags().GetString(flagRegistryName)
specifiedRegistry, err := searchRegistry(registryArg)
if err != nil {
return err
}
query := upstream.Query{
Keyword: args[0],
IncludeClient: client,
FilterEcosystem: resolvedEcosystem,
}
providers, err := deps.Router.ResolveSearchProviders(
query.FilterEcosystem,
specifiedRegistry,
)
if err != nil {
errArg := query.FilterEcosystem.String()
if specifiedRegistry != catalog.RegistryUnspecified {
errArg = specifiedRegistry.String()
}
return fmt.Errorf("%w: %s", err, errArg)
}
results, errs := deps.Router.SearchMany(ctx, providers, query)
if err := ctx.Err(); err != nil {
return err
}
applySearchSort(results, Sort(sortOrder))
if err := searchResultError(results, errs); err != nil {
return err
}
output := cli.NewQueryOutput(cmd, inv)
var noResultSources []string
for _, err := range errs {
if isNoResultsError(err.Err) {
noResultSources = append(noResultSources, err.Provider.Title())
continue
}
providerWarn := fmt.Errorf(
"search on %s failed: %w",
err.Provider.Title(),
err.Err,
)
if output.JSON() {
inv.Logger.Warn(providerWarn)
continue
}
if len(results) > 0 {
inv.Logger.Warn(providerWarn)
}
report.New(cmd.ErrOrStderr(), inv.Theme).Warn(providerWarn)
}
if err := ctx.Err(); err != nil {
return err
}
if output.JSON() {
return searchJSON.Write(output, newSearchResultDocuments(results))
}
var sb strings.Builder
for i, res := range results {
if i > 0 {
sb.WriteString("\n")
}
if long {
sb.WriteString(renderSearchLong(res, inv.Theme, inv.Stdout))
} else {
sb.WriteString(renderSearchCompact(res, inv.Theme))
}
}
if len(noResultSources) > 0 {
sb.WriteString(
inv.Theme.Muted(
"No results from "+strings.Join(noResultSources, ", "),
) + "\n",
)
}
if err := ctx.Err(); err != nil {
return err
}
fmt.Fprint(output.Human(), sb.String())
return nil
}
func searchEcosystem(value string) (catalog.Ecosystem, error) {
if value == "" {
return catalog.EcosystemUnspecified, nil
}
ecosystem := catalog.Ecosystem(strings.ToLower(strings.TrimSpace(value)))
if !ecosystem.IsSearchable() {
return catalog.EcosystemUnspecified, fmt.Errorf("invalid --ecosystem %q", value)
}
return ecosystem, nil
}
func searchRegistry(value string) (catalog.Registry, error) {
if value == "" {
return catalog.RegistryUnspecified, nil
}
registry, ok := catalog.ParseRegistry(value)
if !ok {
return catalog.RegistryUnknown, fmt.Errorf("invalid --registry %q", value)
}
return registry, nil
}
func searchResultError(
results []upstream.SearchResponse,
providerErrors []routing.ProviderError,
) error {
if len(results) > 0 || len(providerErrors) == 0 {
return nil
}
joined := make([]error, 0, len(providerErrors))
for _, providerErr := range providerErrors {
if isNoResultsError(providerErr.Err) {
continue
}
joined = append(joined, providerErr)
}
if len(joined) == 0 {
return nil
}
return errors.Join(joined...)
}
func isNoResultsError(err error) bool {
return err != nil && strings.HasPrefix(err.Error(), "no projects found")
}
func renderSearchCompact(res upstream.SearchResponse, theme style.Theme) string {
var sb strings.Builder
sb.WriteString(searchSectionHeader(res, theme))
if len(res.Items) == 0 {
sb.WriteString(theme.Muted(" (no results)") + "\n")
return sb.String()
}
t := table.New().
Border(lipgloss.HiddenBorder()).
BorderTop(false).
BorderBottom(false).
BorderLeft(false).
BorderRight(false).
BorderColumn(false).
BorderRow(false).
BorderHeader(false).
StyleFunc(
func(row, col int) lipgloss.Style {
if col == 0 {
return lipgloss.NewStyle().Bold(true)
}
return lipgloss.NewStyle().Faint(true)
},
)
for _, item := range res.Items {
desc := item.Description
if desc == "" && item.Title != "" && item.Title != item.ProjectName {
desc = item.Title
}
t.Row(" "+item.DisplayName(), truncateText(desc, 60))
}
sb.WriteString(t.String())
sb.WriteString("\n")
return sb.String()
}
func renderSearchLong(
res upstream.SearchResponse,
theme style.Theme,
terminal style.Terminal,
) string {
var sb strings.Builder
sb.WriteString(searchSectionHeader(res, theme))
if len(res.Items) == 0 {
sb.WriteString(theme.Muted(" (no results)") + "\n")
return sb.String()
}
for i, item := range res.Items {
if i > 0 {
sb.WriteString("\n")
}
displayName := item.DisplayName()
if item.Title != "" && item.Title != item.ProjectName {
displayName = item.Title
}
sb.WriteString(" " + theme.Accent(displayName) + "\n")
if item.Description != "" {
desc := strings.ReplaceAll(item.Description, "\n", " ")
desc = strings.ReplaceAll(desc, "\r", "")
wrapWidth := min(80, terminal.Width) - 2
wrapped := lipgloss.Wrap(desc, wrapWidth, "")
for line := range strings.SplitSeq(wrapped, "\n") {
sb.WriteString(" " + line + "\n")
}
}
stats := searchStatsLine(item)
if stats != "" {
sb.WriteString(" " + theme.Muted(stats) + "\n")
}
installID := item.Provider.String() + ":" + item.ProjectName
sb.WriteString(" " + theme.Muted(installID) + "\n")
}
return sb.String()
}
func searchSectionHeader(
res upstream.SearchResponse,
theme style.Theme,
) string {
header := theme.Muted(
"Results from " + res.Provider.Title() +
" (" + strconv.Itoa(len(res.Items)) + ")",
)
var sb strings.Builder
sb.WriteString(header + "\n")
if res.Provider == catalog.ProviderModrinth && len(res.Items) == 100 {
sb.WriteString(theme.Muted("* only showing the top 100") + "\n")
}
return sb.String()
}
func searchStatsLine(item upstream.SearchResult) string {
var sb strings.Builder
if item.Downloads > 0 {
sb.WriteString(formatDownloads(item.Downloads) + " downloads")
}
if !item.LastUpdated.IsZero() {
if sb.Len() > 0 {
sb.WriteString(", last updated ")
} else {
sb.WriteString("Last updated ")
}
sb.WriteString(timeAgo(item.LastUpdated))
}
return sb.String()
}
func formatDownloads(n int64) string {
switch {
case n >= 1_000_000_000:
return fmt.Sprintf("%.1fB", float64(n)/1e9)
case n >= 1_000_000:
return fmt.Sprintf("%.1fM", float64(n)/1e6)
case n >= 1_000:
return fmt.Sprintf("%.1fK", float64(n)/1e3)
default:
return strconv.FormatInt(n, 10)
}
}
func timeAgo(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
m := int(d.Minutes())
if m == 1 {
return "1 minute ago"
}
return strconv.Itoa(m) + " minutes ago"
case d < 24*time.Hour:
h := int(d.Hours())
if h == 1 {
return "1 hour ago"
}
return strconv.Itoa(h) + " hours ago"
case d < 30*24*time.Hour:
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return strconv.Itoa(days) + " days ago"
case d < 365*24*time.Hour:
months := int(d.Hours() / 24 / 30)
if months <= 1 {
return "1 month ago"
}
return strconv.Itoa(months) + " months ago"
default:
years := int(d.Hours() / 24 / 365)
if years == 1 {
return "1 year ago"
}
return strconv.Itoa(years) + " years ago"
}
}
func truncateText(s string, maxLen int) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", "")
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
package search
import (
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/upstream"
)
// searchResultDocument is the JSON wire shape for one provider's results.
// Warnings become messages because the error values themselves have no
// consumer-meaningful JSON representation.
type searchResultDocument struct {
Provider catalog.Provider `json:"provider"`
Items []upstream.SearchResult `json:"items"`
Warnings []string `json:"warnings,omitempty"`
}
var searchJSON cli.JSONResult[[]searchResultDocument]
func newSearchResultDocuments(
results []upstream.SearchResponse,
) []searchResultDocument {
if results == nil {
return nil
}
documents := make([]searchResultDocument, len(results))
for i, result := range results {
documents[i] = searchResultDocument{
Provider: result.Provider,
Items: result.Items,
Warnings: searchWarningMessages(result.Warnings),
}
}
return documents
}
func searchWarningMessages(warnings []error) []string {
if len(warnings) == 0 {
return nil
}
messages := make([]string, 0, len(warnings))
for _, warning := range warnings {
if warning == nil {
continue
}
messages = append(messages, warning.Error())
}
if len(messages) == 0 {
return nil
}
return messages
}
package search
import (
"cmp"
"sort"
"github.com/mclucy/lucy/internal/cli"
"github.com/mclucy/lucy/upstream"
)
// SearchSort is the user-facing sort order for `lucy search` results. It is
// applied consumer-side to items already returned by providers, which always
// return their own default (relevance) order.
type Sort string
const (
SortRelevance Sort = "relevance"
SortDownloads Sort = "downloads"
SortNewest Sort = "newest"
)
func (s Sort) Valid() bool {
switch s {
case SortRelevance, SortDownloads, SortNewest:
return true
}
return false
}
// applySearchSort reorders items in each response according to s. Relevance
// preserves the provider's order, and unrecognized sorts also fall through as
// no-op rather than failing, so a bad sort never blocks the results.
func applySearchSort(results []upstream.SearchResponse, s Sort) {
switch s {
case "", SortRelevance:
return
case SortDownloads:
for i := range results {
items := results[i].Items
sort.SliceStable(items, func(a, b int) bool {
return cmp.Compare(items[b].Downloads, items[a].Downloads) < 0
})
}
case SortNewest:
for i := range results {
items := results[i].Items
sort.SliceStable(items, func(a, b int) bool {
return items[a].LastUpdated.After(items[b].LastUpdated)
})
}
}
}
func StaticSortCandidates() []cli.CompletionCandidate {
return []cli.CompletionCandidate{
{Value: string(SortRelevance), Description: "Sort by relevance"},
{
Value: string(SortDownloads),
Description: "Sort by download count",
},
{Value: string(SortNewest), Description: "Sort by newest"},
}
}
package fileschema
import (
"encoding/json/v2"
"github.com/mclucy/lucy/internal/fn"
)
type FabricEnvironment string
const (
FabricEnvironmentClient FabricEnvironment = "client"
FabricEnvironmentServer FabricEnvironment = "server"
FabricEnvironmentAny FabricEnvironment = "*"
)
// FabricAuthor handles both a plain string and a person object {"name": "..."}
// as allowed by the fabric.mod.json spec.
type FabricAuthor string
func (a *FabricAuthor) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
*a = FabricAuthor(s)
return nil
}
var obj struct {
Name string `json:"name"`
}
if err := json.Unmarshal(data, &obj); err != nil {
return err
}
*a = FabricAuthor(obj.Name)
return nil
}
// FileFabricModIdentifier represents the structure of fabric.mod.json files found
// in Fabric mods' `.jar` files.
//
// Docs: https://fabricmc.net/wiki/documentation:fabric_mod_json_spec
type FileFabricModIdentifier struct {
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
Version string `json:"version"`
Name string `json:"name"`
Description string `json:"description"`
Authors []FabricAuthor `json:"authors"`
// Fields officially supported:
// - "email"
// - "homepage"
// - "irc"
// - "issues"
// - "sources"
Contact map[string]string `json:"contact"`
// License uses the SPDX license expression format.
License string `json:"license"`
Icon string `json:"icon"`
Environment FabricEnvironment `json:"environment"`
Provides []string `json:"provides"`
Jars []struct {
File string `json:"file"`
} `json:"jars"`
Entrypoints map[string][]string `json:"-"`
Mixins []string `json:"-"`
AccessWidener string `json:"accessWidener"`
LanguageAdapters map[string]string `json:"-"`
// Depends > Recommends > Suggests
// Breaks > Conflicts
Depends map[string]fn.SingleOrSlice[string] `json:"depends"`
Recommends map[string]fn.SingleOrSlice[string] `json:"recommends"`
Suggests map[string]fn.SingleOrSlice[string] `json:"suggests"`
Breaks map[string]fn.SingleOrSlice[string] `json:"breaks"`
Conflicts map[string]fn.SingleOrSlice[string] `json:"conflicts"`
Custom any `json:"-"`
}
package fn
import "io"
// TernaryFunc gives `a` if `expr == true`, `b` if `expr == false`. For a simple
// bool expression, use Ternary instead.
func TernaryFunc[T any](expr func() bool, a T, b T) T {
if expr() {
return a
}
return b
}
// Ternary returns `a` if `v == true`, `b` if `v == false`. For a function parameter, use
// TernaryFunc instead.
//
// Do not use this in a loop or a performance-critical code path, as it may cause
// unnecessary evaluations of a and b.
func Ternary[T any](v bool, a T, b T) T {
if v {
return a
}
return b
}
func TernaryLazy[T any](v bool, a func() T, b func() T) T {
if v {
return a()
}
return b()
}
// CloseReader closes a reader and runs failAction() if error occurs. Call this
// with a defer statement.
func CloseReader(reader io.ReadCloser, failAction func(error)) {
err := reader.Close()
if err != nil {
failAction(err)
}
}
// Decorate applies a series of decorators to a function. This is used to
// prevent nested function calls for better readability.
func Decorate[T any](f T, decorators ...func(T) T) T {
for _, decorator := range decorators {
f = decorator(f)
}
return f
}
func Compose[Tx any, Ty any, Tz any](x func(Tx) Ty, y func(Ty) Tz) func(Tx) Tz {
return func(t Tx) Tz {
return y(x(t))
}
}
package fn
import (
"reflect"
"slices"
)
func ForEach[T any](arr []T, fn func(T)) {
for _, v := range arr {
fn(v)
}
}
func IsEmptyVector[T any](arr []T) bool {
if len(arr) == 0 {
return true
}
for _, e := range arr {
if !isEmptyVectorValue(reflect.ValueOf(e)) {
return false
}
}
return true
}
func isEmptyVectorValue(v reflect.Value) bool {
if !v.IsValid() {
return true
}
if v.Kind() != reflect.Slice {
return false
}
if v.Len() == 0 {
return true
}
for i := 0; i < v.Len(); i++ {
if !isEmptyVectorValue(v.Index(i)) {
return false
}
}
return true
}
func Dedupe[T comparable](arr []T) (res []T) {
set := map[T]struct{}{}
for _, item := range arr {
set[item] = struct{}{}
}
for item := range set {
res = append(res, item)
}
return res
}
// KeyValue works together with SortAndExtract to sort a slice of Item
// with their corresponding Index.
type KeyValue[T, Ti any] struct {
Item T
Index Ti
}
func SortAndExtract[T, Ti any](
arr []KeyValue[T, Ti],
cmp func(a, b KeyValue[T, Ti]) int,
) (res []T) {
slices.SortFunc(arr, cmp)
for _, item := range arr {
res = append(res, item.Item)
}
return res
}
package fn
import "encoding/json/v2"
// SingleOrSlice represents a value that can be encoded as either:
// - a single element: `T`
// - or an array: `[]T`
//
// After unmarshalling, both forms are normalized into a slice shape.
// This type is intended for input-schema compatibility at decoding boundaries.
// Business logic should typically consume plain []T.
type SingleOrSlice[T any] []T
func (s *SingleOrSlice[T]) UnmarshalJSON(data []byte) error {
// Fast-path by JSON shape to avoid ambiguity for permissive T types.
for _, b := range data {
switch b {
case ' ', '\t', '\n', '\r':
continue
case '[':
var multiple []T
if err := json.Unmarshal(data, &multiple); err != nil {
return err
}
*s = multiple
return nil
default:
var single T
if err := json.Unmarshal(data, &single); err != nil {
return err
}
*s = []T{single}
return nil
}
}
// Empty JSON input is treated as an empty slice.
*s = nil
return nil
}
package fsutil
import (
"bufio"
"io"
)
func MoveReaderToLine(r io.Reader, line string) error {
scanner := bufio.NewScanner(r)
for scanner.Scan() {
if scanner.Text() == line {
return nil
}
}
return scanner.Err()
}
package fsutil
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"unicode/utf8"
)
// WorkspaceMarker names the directory that marks a lucy workspace root.
const WorkspaceMarker = ".lucy"
// StagingRelative is the directory inside a workspace that holds staged copies
// written through WriteRoot while the workspace writer lock is held.
const StagingRelative = ".lucy/tmp"
// StagingPrefix names the staged copy of a publish.
const StagingPrefix = ".lucy-stage-"
// MaxStagedBase caps the destination basename embedded in a staging name.
const MaxStagedBase = 64
// RenameRoot publishes a closed, synced file inside root and persists the rename.
// Parent directories must exist. Platform implementations preserve root confinement.
func RenameRoot(root *os.Root, from, to string) error {
return publishRoot(root, from, to, true)
}
// RenameRootNoReplace publishes only if the destination name is still absent.
func RenameRootNoReplace(root *os.Root, from, to string) error {
return publishRoot(root, from, to, false)
}
func publishRoot(root *os.Root, from, to string, replace bool) error {
if !filepath.IsLocal(from) || !filepath.IsLocal(to) {
return fmt.Errorf("rename paths must stay inside their root")
}
from = filepath.Clean(from)
to = filepath.Clean(to)
for _, name := range []string{from, to} {
info, err := root.Lstat(name)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if err == nil && !info.Mode().IsRegular() {
return fmt.Errorf("rename endpoint must be a regular file")
}
for parent := filepath.Dir(name); parent != "."; parent = filepath.Dir(parent) {
info, err := root.Lstat(parent)
if err != nil {
return err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("rename parent must be a regular directory")
}
}
}
return renameRoot(root, from, to, replace)
}
// AtomicWrite replaces a file atomically and retains its permissions.
// New files use defaultMode.
func AtomicWrite(path string, data []byte, defaultMode os.FileMode) error {
mode := defaultMode
info, err := os.Lstat(path)
if err == nil {
if !info.Mode().IsRegular() {
return fmt.Errorf("write destination must be a regular file")
}
mode = info.Mode().Perm()
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect file permissions: %w", err)
}
return atomicCopy(bytes.NewReader(data), path, mode)
}
// CopyFileAtomic discards incomplete copies and returns the published file at offset zero.
func CopyFileAtomic(reader io.Reader, path string, mode os.FileMode) (*os.File, error) {
if err := atomicCopy(reader, path, mode); err != nil {
return nil, err
}
return os.Open(path)
}
func atomicCopy(reader io.Reader, path string, mode os.FileMode) (resultErr error) {
if err := MkdirAllPrivate(filepath.Dir(path)); err != nil {
return fmt.Errorf("create file parent: %w", err)
}
root, err := os.OpenRoot(filepath.Dir(path))
if err != nil {
return fmt.Errorf("open file parent: %w", err)
}
defer func() { resultErr = errors.Join(resultErr, root.Close()) }()
base := filepath.Base(path)
return publish(root, stagedName(base), base, reader, mode, true, nil)
}
// WriteOptions controls one publish.
type WriteOptions struct {
// Mode is the permission set applied to the published file.
Mode os.FileMode
// Replace publishes over an existing destination. Without it the publish
// succeeds only while the destination name is still absent, so a file that
// appears during provisioning survives.
Replace bool
// Check runs after the payload is staged and immediately before
// publication, so a caller can abandon a publish whose destination the
// world changed while the payload was being staged.
Check func() error
}
// WriteRoot publishes a payload at a path confined to root. The payload is
// staged inside the workspace staging directory when root is a workspace and
// beside the destination otherwise, so a publish never crosses a filesystem.
func WriteRoot(root *os.Root, relative string, reader io.Reader, opts WriteOptions) error {
if !filepath.IsLocal(relative) {
return fmt.Errorf("output path must stay inside the workspace")
}
parent := filepath.Dir(relative)
if err := root.MkdirAll(parent, 0o700); err != nil {
return err
}
stageRel, err := stagingRelative(root, parent)
if err != nil {
return err
}
return publish(
root,
filepath.Join(stageRel, stagedName(filepath.Base(relative))),
relative,
reader,
opts.Mode,
opts.Replace,
opts.Check,
)
}
// publish stages a payload and renames it into place. A reader therefore sees
// the previous content or the new content, never a partial write, and a stage
// that a crash abandons never masquerades as destination data.
func publish(
root *os.Root,
stage, dest string,
reader io.Reader,
mode os.FileMode,
replace bool,
check func() error,
) (resultErr error) {
file, err := root.OpenFile(stage, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("create staged file: %w", err)
}
defer func() {
if file != nil {
resultErr = errors.Join(resultErr, file.Close())
}
if err := root.Remove(stage); err != nil && !errors.Is(err, os.ErrNotExist) {
resultErr = errors.Join(resultErr, fmt.Errorf("remove staged file: %w", err))
}
}()
if _, err := io.Copy(file, reader); err != nil {
return fmt.Errorf("write staged file: %w", err)
}
if err := file.Chmod(mode.Perm()); err != nil {
return fmt.Errorf("set staged permissions: %w", err)
}
if err := file.Sync(); err != nil {
return fmt.Errorf("sync staged file: %w", err)
}
if err := file.Close(); err != nil {
file = nil
return fmt.Errorf("close staged file: %w", err)
}
file = nil
if check != nil {
if err := check(); err != nil {
return err
}
}
if replace {
return RenameRoot(root, stage, dest)
}
return RenameRootNoReplace(root, stage, dest)
}
// stagingRelative prefers the workspace staging directory, which keeps a
// staged payload out of the destination namespace, and falls back to the
// destination's own directory for a root that is not a workspace, or when the
// staging directory sits on another filesystem than the destination.
func stagingRelative(root *os.Root, parent string) (string, error) {
info, err := root.Stat(WorkspaceMarker)
if err == nil {
if !info.IsDir() {
return "", fmt.Errorf("%s is not a directory", WorkspaceMarker)
}
if err := root.MkdirAll(StagingRelative, 0o700); err != nil {
return "", fmt.Errorf("create staging directory: %w", err)
}
if sameDevice(filepath.Join(root.Name(), StagingRelative), filepath.Join(root.Name(), parent)) {
return StagingRelative, nil
}
return parent, nil
}
if !errors.Is(err, os.ErrNotExist) {
return "", err
}
return parent, nil
}
// stagedName carries the destination basename so that a stage abandoned by a
// killed process names the file it belongs to.
func stagedName(base string) string {
if len(base) > MaxStagedBase {
cut := MaxStagedBase
for cut > 0 && !utf8.RuneStart(base[cut]) {
cut--
}
base = base[:cut]
}
return StagingPrefix + base + "-" + rand.Text()
}
// SweepStaging removes staging copies that a killed publisher abandoned. The
// caller holds the workspace writer lock, which proves that no publisher is
// live and that every staging copy found is debris.
func SweepStaging(root *os.Root) (removed []string, resultErr error) {
dir, err := root.Open(StagingRelative)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("open staging directory: %w", err)
}
entries, err := dir.ReadDir(-1)
resultErr = errors.Join(err, dir.Close())
if err != nil {
return nil, fmt.Errorf("read staging directory: %w", resultErr)
}
for _, entry := range entries {
if entry.IsDir() || !IsStagingName(entry.Name()) {
continue
}
name := filepath.Join(StagingRelative, entry.Name())
if err := root.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) {
resultErr = errors.Join(resultErr, fmt.Errorf("remove staged file: %w", err))
continue
}
removed = append(removed, name)
}
return removed, resultErr
}
// IsStagingName reports whether name is a staged copy from the current policy.
// Crash debris carries such a name and is inert.
func IsStagingName(name string) bool {
return strings.HasPrefix(name, StagingPrefix)
}
package fsutil
import (
"errors"
"fmt"
"os"
"path/filepath"
)
// MkdirAllPrivate persists newly created naming entries before callers publish data beneath them.
func MkdirAllPrivate(path string) error {
path, err := filepath.Abs(path)
if err != nil {
return err
}
var missing []string
for current := path; ; current = filepath.Dir(current) {
info, err := os.Stat(current)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("directory ancestor is not a directory: %s", current)
}
break
}
if !errors.Is(err, os.ErrNotExist) {
return err
}
missing = append(missing, current)
if filepath.Dir(current) == current {
return fmt.Errorf("filesystem root does not exist: %s: %w", current, err)
}
}
if len(missing) == 0 {
return nil
}
return makePrivateDirectories(path, missing)
}
package fsutil
import (
"errors"
"fmt"
"os"
)
var ErrLocked = errors.New("file is locked")
// FileLock owns its file until Close. Do not copy a lock or close it concurrently.
type FileLock struct {
file *os.File
}
// LockFile waits for exclusive ownership of a private lock file.
func LockFile(path string) (*FileLock, error) {
return openLock(path, true)
}
// TryLockFile returns ErrLocked instead of waiting for another owner.
func TryLockFile(path string) (*FileLock, error) {
return openLock(path, false)
}
func openLock(path string, wait bool) (*FileLock, error) {
file, err := openLockFile(path)
if err != nil {
return nil, fmt.Errorf("open lock: %w", err)
}
return LockOpenedFile(file, wait)
}
// LockOpenedFile takes ownership of file even if acquisition fails.
func LockOpenedFile(file *os.File, wait bool) (*FileLock, error) {
if err := lockFile(file, wait); err != nil {
return nil, errors.Join(err, file.Close())
}
return &FileLock{file: file}, nil
}
// Close releases ownership. A second Close has no effect.
func (l *FileLock) Close() error {
if l == nil || l.file == nil {
return nil
}
file := l.file
l.file = nil
return errors.Join(unlockFile(file), file.Close())
}
//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
package fsutil
import (
"errors"
"fmt"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
func openLockFile(path string) (*os.File, error) {
return os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
}
func lockFile(file *os.File, wait bool) error {
flags := unix.LOCK_EX
if !wait {
flags |= unix.LOCK_NB
}
err := unix.Flock(int(file.Fd()), flags)
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return fmt.Errorf("lock file: %w: %w", ErrLocked, err)
}
return err
}
func unlockFile(file *os.File) error {
return unix.Flock(int(file.Fd()), unix.LOCK_UN)
}
func renameRoot(root *os.Root, from, to string, replace bool) error {
if !replace {
if err := root.Link(from, to); err != nil {
return fmt.Errorf("publish absent name in root: %w", err)
}
if err := syncRenameChain(root, to); err != nil {
return err
}
if err := root.Remove(from); err != nil {
return err
}
return syncRenameChain(root, from)
}
if err := root.Rename(from, to); err != nil {
return fmt.Errorf("rename in root: %w", err)
}
return syncRenameChain(root, from, to)
}
// sameDevice reports whether both paths live on one filesystem, so that a
// staged copy reaches its destination through a rename. When either path
// cannot be stated, it reports false and the publish stages beside the
// destination instead, which is always on the destination filesystem.
func sameDevice(first, second string) bool {
var firstStat, secondStat unix.Stat_t
if err := unix.Stat(first, &firstStat); err != nil {
return false
}
if err := unix.Stat(second, &secondStat); err != nil {
return false
}
return firstStat.Dev == secondStat.Dev
}
// syncRenameChain flushes the directory entries that a rename changes: the
// parent directories of both endpoints and every directory from those parents
// up to root.
func syncRenameChain(root *os.Root, names ...string) error {
var resultErr error
for _, name := range names {
for dir := filepath.Dir(name); ; dir = filepath.Dir(dir) {
resultErr = errors.Join(resultErr, syncDir(root, dir))
if dir == "." {
break
}
}
}
return resultErr
}
func syncDir(root *os.Root, dir string) error {
file, err := root.OpenFile(dir, os.O_RDONLY, 0)
if err != nil {
return fmt.Errorf("open directory for sync: %w", err)
}
var resultErr error
if err := file.Sync(); err != nil {
resultErr = fmt.Errorf("sync directory: %w", err)
}
if err := file.Close(); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("close directory: %w", err))
}
return resultErr
}
func makePrivateDirectories(path string, missing []string) error {
if err := os.MkdirAll(path, 0o700); err != nil {
return err
}
for _, directory := range missing {
parent, err := os.Open(filepath.Dir(directory))
if err != nil {
return err
}
syncErr := parent.Sync()
closeErr := parent.Close()
if syncErr != nil || closeErr != nil {
return errors.Join(syncErr, closeErr)
}
}
return nil
}
package fsutil
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
)
// MaxZipEntryBytes is the upper bound for reading a single entry from a JAR/ZIP
// when the full payload is required for parsing (metadata descriptors).
const MaxZipEntryBytes = 32 * 1024 * 1024
// MaxNestedZipEntryBytes is the upper bound for reading a ZIP entry that
// embeds another archive, such as a nested JAR or an installer payload.
const MaxNestedZipEntryBytes = 128 * 1024 * 1024
// ReadZipEntry returns the contents of the named entry. found reports whether
// the archive carries that entry. An entry declared larger than maxBytes fails
// before decompression starts; maxBytes <= 0 disables the bound.
func ReadZipEntry(
z *zip.Reader,
name string,
maxBytes int64,
) (data []byte, found bool, err error) {
for _, f := range z.File {
if f.Name != name {
continue
}
if maxBytes > 0 && f.UncompressedSize64 > uint64(maxBytes) {
return nil, true, fmt.Errorf(
"zip entry %s exceeds %d bytes",
name,
maxBytes,
)
}
r, err := f.Open()
if err != nil {
return nil, true, err
}
data, readErr := CopyBytes(r, maxBytes)
return data, true, errors.Join(readErr, r.Close())
}
return nil, false, nil
}
// CopyBytes reads r into memory. When maxBytes > 0, r is wrapped in
// io.LimitReader(max+1) so reads fail if the stream exceeds the limit.
func CopyBytes(r io.Reader, maxBytes int64) ([]byte, error) {
limited := r
if maxBytes > 0 {
limited = io.LimitReader(r, maxBytes+1)
}
var buf bytes.Buffer
if _, err := io.Copy(&buf, limited); err != nil {
return nil, err
}
if maxBytes > 0 && int64(buf.Len()) > maxBytes {
return nil, fmt.Errorf("read exceeded %d bytes", maxBytes)
}
return buf.Bytes(), nil
}
// Package shell formats text that a POSIX shell reads back.
package shell
import "strings"
// Quote returns s safe to paste into a POSIX shell command. Plain words stay
// bare so quoted output stays readable; anything a shell would interpret is
// single-quoted.
func Quote(s string) string {
if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$`!*?[]{}()<>|&;#~") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
package toolchain
import (
"cmp"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
"time"
javapkg "github.com/mclucy/lucy/java"
)
const probeTimeout = 20 * time.Second
type JavaInstallation struct {
Home string
Java string
Javac string
Version string
Major int
Vendor string
Arch string
Distribution javapkg.Distribution
Implementor string
}
type GradleInstallation struct {
Path string
Version string
}
type Inventory struct {
Java []JavaInstallation
Gradle *GradleInstallation
JavaHome string
PathJava string
GradleUserHome string
Problems []error
}
// Discover probes installed executables without provisioning tools. Missing tools
// and unusable candidates are inventory results; additional homes are local JDK hints.
func Discover(ctx context.Context, homes ...string) (Inventory, error) {
if err := ctx.Err(); err != nil {
return Inventory{}, err
}
environment, err := readEnv()
if err != nil {
return Inventory{}, err
}
inventory := Inventory{
JavaHome: environment.javaHome, PathJava: environment.pathJava,
GradleUserHome: environment.gradleUserHome,
}
candidates := javaCandidates(environment, homes)
results := make([]JavaInstallation, len(candidates))
problems := make([]error, len(candidates))
concurrency := make(chan struct{}, 4)
var workers sync.WaitGroup
for i, candidate := range candidates {
workers.Go(func() {
select {
case concurrency <- struct{}{}:
defer func() { <-concurrency }()
case <-ctx.Done():
return
}
results[i], problems[i] = probeJava(ctx, candidate)
})
}
workers.Wait()
if err := ctx.Err(); err != nil {
return Inventory{}, err
}
seen := make(map[string]bool, len(results))
for i, java := range results {
if problems[i] != nil {
inventory.Problems = append(inventory.Problems, problems[i])
continue
}
if java.Home != "" && !seen[java.Home] {
seen[java.Home] = true
inventory.Java = append(inventory.Java, java)
}
}
sort.Slice(inventory.Java, func(i, j int) bool {
a, b := &inventory.Java[i], &inventory.Java[j]
return cmp.Or(cmp.Compare(b.Major, a.Major), cmp.Compare(a.Home, b.Home)) < 0
})
inventory.Gradle, err = probeGradle(ctx)
if err != nil {
inventory.Problems = append(inventory.Problems, err)
}
if err := ctx.Err(); err != nil {
return Inventory{}, err
}
return inventory, nil
}
type envVars struct {
javaHome string
pathJava string
gradleUserHome string
home string
path string
}
func readEnv() (envVars, error) {
home, err := os.UserHomeDir()
if err != nil {
return envVars{}, fmt.Errorf("find user home: %w", err)
}
environment := envVars{
javaHome: os.Getenv("JAVA_HOME"), gradleUserHome: os.Getenv("GRADLE_USER_HOME"),
home: home, path: os.Getenv("PATH"),
}
if environment.gradleUserHome == "" {
environment.gradleUserHome = filepath.Join(home, ".gradle")
}
if java, err := exec.LookPath(javaExecutableName); err == nil {
environment.pathJava = java
}
return environment, nil
}
func canonical(path string) string {
resolved, err := filepath.EvalSymlinks(path)
if err == nil {
return filepath.Clean(resolved)
}
return filepath.Clean(path)
}
func probeEnv() []string {
environment := os.Environ()
return slices.DeleteFunc(environment, func(entry string) bool {
key, _, _ := strings.Cut(entry, "=")
switch strings.ToUpper(key) {
case "_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS", "JDK_JAVA_OPTIONS":
return true
default:
return false
}
})
}
package toolchain
import (
"context"
"fmt"
"os/exec"
"strings"
)
func probeGradle(ctx context.Context) (*GradleInstallation, error) {
path, err := exec.LookPath("gradle")
if err != nil {
return nil, nil
}
if managerShim(path) {
return nil, fmt.Errorf("gradle on PATH is a version-manager shim; select an installed distribution")
}
output, err := runProbe(ctx, path, "--version")
if err != nil {
return nil, fmt.Errorf("probe Gradle %q: %w", path, err)
}
for line := range strings.SplitSeq(output, "\n") {
version, ok := strings.CutPrefix(strings.TrimSpace(line), "Gradle ")
if ok {
version, _, _ = strings.Cut(version, " ")
if version != "" {
return &GradleInstallation{
Path: canonical(path),
Version: version,
}, nil
}
}
}
return nil, fmt.Errorf("gradle %q reported no version", path)
}
package toolchain
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
type javaCandidate struct {
java string
source string
}
func javaCandidates(environment envVars, hints []string) []javaCandidate {
candidates := make([]javaCandidate, 0, len(hints)+2)
seen := make(map[string]bool)
add := func(executable, source string) {
if executable == "" || managerShim(executable) {
return
}
key := canonical(executable)
if !seen[key] {
seen[key] = true
candidates = append(candidates, javaCandidate{java: executable, source: source})
}
}
if environment.javaHome != "" {
add(filepath.Join(environment.javaHome, "bin", javaExecutableName), "JAVA_HOME")
}
add(environment.pathJava, "PATH")
for _, home := range hints {
if home != "" {
add(filepath.Join(home, "bin", javaExecutableName), "Gradle configuration")
}
}
roots := systemJavaRoots()
sdkman := os.Getenv("SDKMAN_DIR")
if sdkman == "" {
sdkman = filepath.Join(environment.home, ".sdkman")
}
mise := os.Getenv("MISE_DATA_DIR")
if mise == "" {
mise = filepath.Join(environment.home, ".local", "share", "mise")
}
asdf := os.Getenv("ASDF_DATA_DIR")
if asdf == "" {
asdf = filepath.Join(environment.home, ".asdf")
}
roots = append(roots,
filepath.Join(sdkman, "candidates", "java"),
filepath.Join(mise, "installs", "java"),
filepath.Join(asdf, "installs", "java"),
filepath.Join(environment.home, ".jabba", "jdk"),
filepath.Join(environment.gradleUserHome, "jdks"),
)
if managed, err := ManagedJavaRoot(); err == nil {
roots = append(roots, managed)
}
for _, root := range roots {
for _, home := range javaHomesUnder(root) {
add(filepath.Join(home, "bin", javaExecutableName), root)
}
}
return candidates
}
func javaHomesUnder(root string) []string {
homes := make([]string, 0)
add := func(dir string) bool {
for _, suffix := range []string{"", filepath.Join("Contents", "Home")} {
home := filepath.Join(dir, suffix)
if isExecutable(filepath.Join(home, "bin", javaExecutableName)) {
homes = append(homes, home)
return true
}
}
return false
}
if add(root) {
return homes
}
entries, err := os.ReadDir(root)
if err != nil {
return homes
}
for _, entry := range entries {
child := filepath.Join(root, entry.Name())
if add(child) {
continue
}
if add(filepath.Join(child, "current")) {
continue
}
if filepath.Base(root) != "jdks" {
continue
}
nested, err := os.ReadDir(child)
if err != nil {
continue
}
for _, entry := range nested {
add(filepath.Join(child, entry.Name()))
}
}
return homes
}
func probeJava(ctx context.Context, candidate javaCandidate) (JavaInstallation, error) {
if !isExecutable(candidate.java) {
return JavaInstallation{}, fmt.Errorf("java candidate %q from %s is not executable", candidate.java, candidate.source)
}
output, err := runProbe(ctx, candidate.java, "-XshowSettings:properties", "-version")
if err != nil {
return JavaInstallation{}, fmt.Errorf("probe java %q: %w", candidate.java, err)
}
properties := make(map[string]string)
for line := range strings.SplitSeq(output, "\n") {
key, value, ok := strings.Cut(strings.TrimSpace(line), " = ")
if ok {
properties[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
}
if properties["java.home"] == "" {
return JavaInstallation{}, fmt.Errorf("java %q reported no installation home", candidate.java)
}
home := canonical(properties["java.home"])
if filepath.Base(home) == "jre" {
parent := filepath.Dir(home)
if isExecutable(filepath.Join(parent, "bin", javacExecutableName)) {
home = parent
}
}
release := releaseProperties(home)
installation := JavaInstallation{
Home: home, Java: filepath.Join(home, "bin", javaExecutableName),
Version: properties["java.version"], Major: majorVersion(properties["java.version"]),
Vendor: properties["java.vendor"], Arch: properties["os.arch"],
Distribution: distributionFromRelease(release),
Implementor: release["IMPLEMENTOR"],
}
if installation.Major == 0 || !isExecutable(installation.Java) {
return JavaInstallation{}, fmt.Errorf("java %q reported an unusable installation", candidate.java)
}
javac := filepath.Join(home, "bin", javacExecutableName)
if isExecutable(javac) {
if _, err := runProbe(ctx, javac, "-version"); err == nil {
installation.Javac = javac
}
}
return installation, nil
}
// ProbeJava inspects the Java installation at path. path is a Java home or a
// java executable, and symlinks are allowed. An invalid path yields an error
// that names the defect.
func ProbeJava(ctx context.Context, path string) (JavaInstallation, error) {
if strings.TrimSpace(path) == "" {
return JavaInstallation{}, errors.New("probe Java: path is empty")
}
info, err := os.Stat(path)
if err != nil {
return JavaInstallation{}, fmt.Errorf("probe Java %q: %w", path, err)
}
executable := path
if info.IsDir() {
executable = filepath.Join(path, "bin", javaExecutableName)
if !isExecutable(executable) {
executable = filepath.Join(path, "Contents", "Home", "bin", javaExecutableName)
}
if !isExecutable(executable) {
return JavaInstallation{}, fmt.Errorf(
"probe Java home %q: no java executable under bin or Contents/Home/bin", path)
}
} else if !isExecutable(path) {
return JavaInstallation{}, fmt.Errorf("probe Java %q: not an executable file", path)
}
return probeJava(ctx, javaCandidate{java: executable, source: "requested path"})
}
func majorVersion(version string) int {
version = strings.TrimPrefix(version, "1.")
end := strings.IndexFunc(version, func(r rune) bool { return r < '0' || r > '9' })
if end >= 0 {
version = version[:end]
}
major, err := strconv.Atoi(version)
if err != nil {
return 0
}
return major
}
func runProbe(ctx context.Context, executable string, arguments ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
cmd := probeCommand(ctx, executable, arguments...)
cmd.Env = probeEnv()
cmd.WaitDelay = time.Second
output, err := cmd.CombinedOutput()
if ctx.Err() != nil {
return "", ctx.Err()
}
return string(output), err
}
func isExecutable(path string) bool {
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
return false
}
if runtime.GOOS == "windows" {
switch strings.ToLower(filepath.Ext(path)) {
case ".exe", ".bat", ".cmd":
return true
default:
return false
}
}
return info.Mode().Perm()&0o111 != 0
}
func managerShim(path string) bool {
normalized := "/" + strings.ToLower(filepath.ToSlash(path)) + "/"
return strings.Contains(normalized, "/shims/")
}
package toolchain
import (
"context"
"os"
"os/exec"
"path/filepath"
)
const (
javaExecutableName = "java"
javacExecutableName = "javac"
)
func systemJavaRoots() []string {
roots := []string{"/usr/lib/jvm", "/usr/java", "/opt/java", "/home/linuxbrew/.linuxbrew/opt"}
if home, err := os.UserHomeDir(); err == nil {
roots = append(roots, filepath.Join(home, ".linuxbrew", "opt"))
}
return roots
}
func probeCommand(ctx context.Context, executable string, arguments ...string) *exec.Cmd {
return exec.CommandContext(ctx, executable, arguments...)
}
package toolchain
import (
"errors"
"os"
"path/filepath"
"runtime"
)
// ManagedJavaRoot returns the per-user directory that holds the Java runtimes
// Lucy downloads. Each runtime is one child directory of the root.
func ManagedJavaRoot() (string, error) {
switch runtime.GOOS {
case "windows":
base := os.Getenv("LOCALAPPDATA")
if base == "" {
return "", errors.New("LOCALAPPDATA is not set")
}
return filepath.Join(base, "lucy", "java"), nil
case "darwin":
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "Library", "Application Support", "lucy", "java"), nil
default:
if data := os.Getenv("XDG_DATA_HOME"); filepath.IsAbs(data) {
return filepath.Join(data, "lucy", "java"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share", "lucy", "java"), nil
}
}
package toolchain
import (
"os"
"path/filepath"
"strings"
"github.com/mclucy/lucy/java"
)
// releaseProperties reads the release file of a Java home. A missing or
// unreadable file yields an empty map. Lines are KEY="value"; quotes and
// surrounding blanks are optional, and CRLF is accepted.
func releaseProperties(home string) map[string]string {
properties := make(map[string]string)
data, err := os.ReadFile(filepath.Join(home, "release"))
if err != nil {
return properties
}
for line := range strings.SplitSeq(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
if key == "" {
continue
}
value = strings.TrimSpace(value)
value = strings.Trim(value, `"`)
properties[key] = value
}
return properties
}
// distributionFromRelease maps the IMPLEMENTOR value to a distribution. A
// vendor Lucy does not know is an unbranded build for the purpose of matching
// a requirement. Oracle builds need extra keys to tell their three products
// apart.
func distributionFromRelease(properties map[string]string) java.Distribution {
implementor := properties["IMPLEMENTOR"]
if distribution, ok := implementorDistributions[implementor]; ok {
return distribution
}
if implementor != "Oracle Corporation" {
return java.DistributionAny
}
switch {
case properties["GRAALVM_VERSION"] != "":
return java.DistributionGraalVM
case properties["BUILD_TYPE"] == "commercial":
return java.DistributionOracle
default:
return java.DistributionOracleOpenJDK
}
}
// implementorDistributions lists the IMPLEMENTOR values of the vendors Lucy
// installs. Downloadable Oracle GraalVM reports Oracle Corporation and needs
// the GRAALVM_VERSION key, so it is not listed here.
var implementorDistributions = map[string]java.Distribution{
"Eclipse Adoptium": java.DistributionTemurin,
"Azul Systems, Inc.": java.DistributionZulu,
"Amazon.com Inc.": java.DistributionCorretto,
"BellSoft": java.DistributionLiberica,
"Microsoft": java.DistributionMicrosoft,
"GraalVM Community": java.DistributionGraalVMCommunity,
"IBM Corporation": java.DistributionSemeru,
"SAP SE": java.DistributionSapMachine,
"Alibaba": java.DistributionDragonwell,
"Tencent": java.DistributionKona,
"Red Hat, Inc.": java.DistributionRedHat,
"JetBrains s.r.o.": java.DistributionJetBrains,
}
// Package workspacewrite coordinates recoverable filesystem writes without runtime ownership policy.
package workspacewrite
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/log"
"gopkg.in/yaml.v3"
)
const (
InstallJournalName = ".lucy/commit.yaml"
// LockFileName names the writer lock inside the workspace state directory.
LockFileName = "writer.lock"
)
// InstallImage describes one content state of a path. A nil image means the
// path is absent.
type InstallImage struct {
SHA256 string `yaml:"sha256"`
Mode uint32 `yaml:"mode"`
}
// InstallJournalEntry records the before and after content and permission
// state of one path that a commit may touch. A nil Before marks a path the
// commit creates; a nil After marks a path the commit prunes. Content hashes
// and modes identify whether recovery may reverse the transition; Mode also
// supplies the permissions to restore.
type InstallJournalEntry struct {
Path string `yaml:"path"`
Before *InstallImage `yaml:"before,omitempty"`
After *InstallImage `yaml:"after,omitempty"`
}
// InstallJournal is the installation rollback record. Backup is relative to
// the workspace root, so moving the workspace keeps it valid. Committed marks
// the commit point: once set, recovery only removes rollback data and never
// rewrites workspace files. Decoding is strict, so a legacy changed/created
// journal or any unknown field is refused instead of reinterpreted.
type InstallJournal struct {
Backup string `yaml:"backup"`
Committed bool `yaml:"committed,omitempty"`
Entries []InstallJournalEntry `yaml:"entries"`
}
// RecoveryCleanupError reports that recovery of a committed installation could
// not remove its rollback data. Installed files and documents stay in place.
type RecoveryCleanupError struct{ Err error }
func (e *RecoveryCleanupError) Error() string { return e.Err.Error() }
func (e *RecoveryCleanupError) Unwrap() error { return e.Err }
// Lock is shared by installation and explicit file operations. It reclaims
// staging that a killed publisher abandoned, which is safe only under the
// writer lock it takes, because that lock proves no publisher is live.
func Lock(root string, logger *log.Logger) (*fsutil.FileLock, error) {
if err := SafePath(root, ".lucy"); err != nil {
return nil, err
}
if err := fsutil.MkdirAllPrivate(filepath.Join(root, ".lucy")); err != nil {
return nil, err
}
if err := SafePath(root, ".lucy/"+LockFileName); err != nil {
return nil, err
}
lock, err := fsutil.TryLockFile(filepath.Join(root, ".lucy", LockFileName))
if err != nil {
if errors.Is(err, fsutil.ErrLocked) {
return nil, fmt.Errorf("another Lucy operation owns this workspace: %w", err)
}
return nil, err
}
files, err := os.OpenRoot(root)
if err != nil {
return nil, errors.Join(err, lock.Close())
}
removed, sweepErr := fsutil.SweepStaging(files)
closeErr := files.Close()
if sweepErr != nil {
logger.Warn(fmt.Errorf("reclaim stale staging files: %w", sweepErr))
}
for _, name := range removed {
logger.Debug("removed stale staging file", "path", name)
}
if closeErr != nil {
return nil, errors.Join(closeErr, lock.Close())
}
return lock, nil
}
// SafePath rejects existing symlink components before root-confined IO.
func SafePath(root, relative string) error {
if !filepath.IsLocal(relative) {
return fmt.Errorf("output path must stay inside the workspace")
}
current := root
for component := range strings.SplitSeq(filepath.Clean(relative), string(filepath.Separator)) {
current = filepath.Join(current, component)
info, err := os.Lstat(current)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("output path traverses symlink: %s", relative)
}
}
return nil
}
// RemoveRoot persists disappearance from its original name; leftovers are private cleanup data.
func RemoveRoot(root *os.Root, relative string) error {
if !filepath.IsLocal(relative) {
return fmt.Errorf("output path must stay inside the workspace")
}
if _, err := root.Lstat(relative); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return err
}
if err := root.MkdirAll(".lucy/tmp", 0o700); err != nil {
return err
}
garbage := ".lucy/tmp/removed-" + rand.Text()
if err := fsutil.RenameRoot(root, relative, garbage); err != nil {
return err
}
if err := root.Chmod(garbage, 0o600); err != nil {
return err
}
return root.Remove(garbage)
}
// RecoverInstall must run with workspace ownership before reading state for a new mutation.
func RecoverInstall(root string, logger *log.Logger) (resultErr error) {
journal, err := readInstallJournal(root)
if err != nil {
return err
}
if journal == nil {
return nil
}
if err := CheckInactive(root, "recovering an interrupted installation"); err != nil {
return err
}
backup, err := installJournalBackup(root, journal.Backup)
if err != nil {
return err
}
journalPath := filepath.Join(root, InstallJournalName)
if journal.Committed {
logger.Warn(
fmt.Errorf(
"completing cleanup of committed installation transaction %s",
journalPath,
),
)
if err := cleanupCommittedInstall(root, backup, journalPath, logger); err != nil {
return err
}
return nil
}
if err := validateInstallJournal(root, journal); err != nil {
return err
}
logger.Warn(
fmt.Errorf(
"recovering interrupted installation transaction %s: %d recorded paths",
journalPath, len(journal.Entries),
),
)
files, err := os.OpenRoot(root)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, files.Close()) }()
plan := make([]InstallJournalEntry, 0, len(journal.Entries))
refused := make([]string, 0)
for _, entry := range journal.Entries {
current, err := observeInstallImage(files, filepath.FromSlash(entry.Path))
if err != nil {
return err
}
switch classifyInstallEntry(current, entry.Before, entry.After) {
case installEntryUnapplied:
continue
case installEntryApplied:
if err := checkInstallPreImage(files, backup, entry); err != nil {
return err
}
plan = append(plan, entry)
default:
refused = append(refused, entry.Path)
}
}
if len(refused) > 0 {
return fmt.Errorf(
"installation recovery refused paths edited after the interruption in %s; keep the journal and resolve these edits: %s",
journalPath, strings.Join(refused, ", "),
)
}
for _, entry := range plan {
if err := restoreInstallEntry(files, backup, entry); err != nil {
return err
}
}
if err := files.RemoveAll(backup); err != nil {
return err
}
if err := RemoveRoot(files, InstallJournalName); err != nil {
return err
}
logger.Info(
"installation transaction recovered",
"root", root,
"journal", journalPath,
)
return nil
}
// InstallJournalCommitted reports whether a pending installation journal
// reached its commit point. It is a read-only query for a committing caller
// whose committed-marker publication reported an error after the atomic
// replacement may already have happened.
func InstallJournalCommitted(root string) (bool, error) {
journal, err := readInstallJournal(root)
if err != nil {
return false, err
}
return journal != nil && journal.Committed, nil
}
func readInstallJournal(root string) (*InstallJournal, error) {
if err := SafePath(root, InstallJournalName); err != nil {
return nil, err
}
raw, err := os.ReadFile(filepath.Join(root, InstallJournalName))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var journal InstallJournal
decoder := yaml.NewDecoder(bytes.NewReader(raw))
decoder.KnownFields(true)
if err := decoder.Decode(&journal); err != nil {
return nil, fmt.Errorf("decode installation journal: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return nil, fmt.Errorf("installation journal must contain one YAML document")
}
return nil, fmt.Errorf("decode installation journal tail: %w", err)
}
return &journal, nil
}
// installJournalBackup returns the workspace-relative rollback directory.
// Journals never carry an absolute path or an alias outside .lucy.
func installJournalBackup(root, backup string) (string, error) {
relative := filepath.Clean(filepath.FromSlash(backup))
if !filepath.IsLocal(relative) ||
filepath.ToSlash(relative) != backup ||
filepath.Dir(relative) != ".lucy" ||
!strings.HasPrefix(filepath.Base(relative), "rollback-") {
return "", fmt.Errorf("invalid installation rollback directory")
}
if err := SafePath(root, relative); err != nil {
return "", err
}
return relative, nil
}
func validateInstallJournal(root string, journal *InstallJournal) error {
folded := make(map[string][]string, len(journal.Entries))
for _, entry := range journal.Entries {
if entry.Path == "" || !filepath.IsLocal(filepath.FromSlash(entry.Path)) {
return fmt.Errorf("invalid installation journal path %q", entry.Path)
}
if filepath.ToSlash(filepath.Clean(filepath.FromSlash(entry.Path))) != entry.Path {
return fmt.Errorf("installation journal path %q is not canonical", entry.Path)
}
if entry.Path == InstallJournalName ||
(strings.EqualFold(entry.Path, InstallJournalName) &&
InstallPathsAlias(root, InstallJournalName, entry.Path)) {
return fmt.Errorf("installation journal cannot record itself")
}
key := strings.ToLower(entry.Path)
for _, prior := range folded[key] {
if InstallPathsAlias(root, prior, entry.Path) {
return fmt.Errorf(
"installation journal paths %q and %q alias the same filesystem entry",
prior, entry.Path,
)
}
}
folded[key] = append(folded[key], entry.Path)
if entry.Before == nil && entry.After == nil {
return fmt.Errorf(
"installation journal path %q has neither before nor after image",
entry.Path,
)
}
for _, image := range []*InstallImage{entry.Before, entry.After} {
if image == nil {
continue
}
if err := validateInstallImage(image); err != nil {
return fmt.Errorf(
"invalid installation journal image for %s: %w",
entry.Path, err,
)
}
}
if err := SafePath(root, filepath.FromSlash(entry.Path)); err != nil {
return err
}
}
return nil
}
func validateInstallImage(image *InstallImage) error {
if len(image.SHA256) != sha256.Size*2 {
return fmt.Errorf("sha256 must be %d hex characters", sha256.Size*2)
}
if _, err := hex.DecodeString(image.SHA256); err != nil {
return fmt.Errorf("sha256 is not hex: %w", err)
}
if image.Mode&^uint32(0o777) != 0 {
return fmt.Errorf("mode %#o has non-permission bits", image.Mode)
}
return nil
}
func cleanupCommittedInstall(root, backup, journalPath string, logger *log.Logger) (resultErr error) {
files, err := os.OpenRoot(root)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, files.Close()) }()
if err := files.RemoveAll(backup); err != nil {
return &RecoveryCleanupError{Err: fmt.Errorf(
"installation committed; remove rollback backup %s: %w", backup, err,
)}
}
if err := RemoveRoot(files, InstallJournalName); err != nil {
return &RecoveryCleanupError{Err: fmt.Errorf(
"installation committed; remove transaction journal: %w", err,
)}
}
logger.Info(
"installation transaction cleanup completed",
"root", root,
"journal", journalPath,
)
return nil
}
// installEntryState describes how far a commit got with one journal path.
type installEntryState int
const (
// installEntryUnapplied means the path still holds its before image, so
// recovery leaves it alone.
installEntryUnapplied installEntryState = iota
// installEntryApplied means the path holds its after image, so recovery
// reverses the transition using the complete before image.
installEntryApplied
// installEntryModified means the path holds neither image, so something
// edited it after the interruption. Recovery must not destroy that edit.
installEntryModified
)
func classifyInstallEntry(current, before, after *InstallImage) installEntryState {
// Modes distinguish states only when content is equal. If content differs,
// some filesystems cannot represent the requested mode, so requiring a mode
// match would misclassify their own committed bytes as an external edit.
compareMode := before != nil && after != nil &&
before.SHA256 == after.SHA256
if installImagesMatch(current, before, compareMode) {
return installEntryUnapplied
}
if installImagesMatch(current, after, compareMode) {
return installEntryApplied
}
return installEntryModified
}
func installImagesMatch(
current, expected *InstallImage,
compareMode bool,
) bool {
if current == nil || expected == nil {
return current == nil && expected == nil
}
if current.SHA256 != expected.SHA256 {
return false
}
return !compareMode || current.Mode == expected.Mode
}
// InstallPathsAlias reports whether two workspace-relative paths would resolve
// to the same filesystem entry. Existing shared entries are detected directly.
// Otherwise each case-differing component is checked against the filesystem of
// its parent directory, so a nested mount's semantics win over the workspace
// root's. An unprovable parent is treated as aliasing so callers refuse rather
// than risk data loss.
func InstallPathsAlias(root, left, right string) bool {
leftInfo, leftErr := os.Lstat(filepath.Join(root, filepath.FromSlash(left)))
rightInfo, rightErr := os.Lstat(filepath.Join(root, filepath.FromSlash(right)))
if leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) {
return true
}
leftParts := strings.Split(filepath.ToSlash(left), "/")
rightParts := strings.Split(filepath.ToSlash(right), "/")
if len(leftParts) != len(rightParts) {
return false
}
prefix := ""
for i := range leftParts {
leftPart, rightPart := leftParts[i], rightParts[i]
if leftPart == rightPart {
prefix = nextInstallPrefix(prefix, leftPart)
continue
}
if !strings.EqualFold(leftPart, rightPart) {
return false
}
folded, known := installPrefixFoldsCase(root, prefix)
if !known {
return true
}
if !folded {
return false
}
prefix = nextInstallPrefix(prefix, leftPart)
}
return true
}
func nextInstallPrefix(prefix, component string) string {
if prefix == "" {
return component
}
return prefix + "/" + component
}
func installPrefixFoldsCase(root, prefix string) (folded, known bool) {
if prefix == "" {
return installRootFoldsCase(root)
}
return installPathFoldsCase(
root,
filepath.Join(filepath.FromSlash(prefix), "case-probe"),
)
}
// installPathFoldsCase reports whether the nearest existing parent directory
// of relative treats case variants as the same name. known is false when the
// directory has no usable entry to prove either behavior.
func installPathFoldsCase(root, relative string) (folded, known bool) {
dir := filepath.Dir(filepath.FromSlash(relative))
for {
if dir == "." || dir == string(filepath.Separator) {
return installRootFoldsCase(root)
}
absolute := filepath.Join(root, dir)
info, err := os.Lstat(absolute)
switch {
case err == nil:
if !info.IsDir() {
return true, false
}
return installDirectoryFoldsCase(absolute)
case errors.Is(err, os.ErrNotExist):
parent := filepath.Dir(dir)
if parent == dir {
return installRootFoldsCase(root)
}
dir = parent
default:
return true, false
}
}
}
// installRootFoldsCase probes the workspace state directory, which the writer
// lock guarantees exists. A case variant of .lucy resolving to the same
// directory proves a case-insensitive root filesystem.
func installRootFoldsCase(root string) (folded, known bool) {
lower, err := os.Lstat(filepath.Join(root, ".lucy"))
if err != nil {
return true, false
}
upper, err := os.Lstat(filepath.Join(root, ".LUCY"))
switch {
case err == nil:
return os.SameFile(lower, upper), true
case errors.Is(err, os.ErrNotExist):
return false, true
default:
return true, false
}
}
// installDirectoryFoldsCase probes one existing directory with one of its own
// entries. An entry whose case variant resolves to the same file proves a
// case-insensitive directory filesystem; a missing or distinct variant proves
// a case-sensitive one.
func installDirectoryFoldsCase(directory string) (folded, known bool) {
entries, err := os.ReadDir(directory)
if err != nil {
return true, false
}
for _, entry := range entries {
variant := flipNameCase(entry.Name())
if variant == "" || variant == entry.Name() {
continue
}
original, err := entry.Info()
if err != nil {
continue
}
probe, err := os.Lstat(filepath.Join(directory, variant))
switch {
case err == nil:
return os.SameFile(original, probe), true
case errors.Is(err, os.ErrNotExist):
return false, true
}
}
return true, false
}
// flipNameCase changes the case of the first ASCII letter in name.
func flipNameCase(name string) string {
for i := range len(name) {
switch {
case name[i] >= 'a' && name[i] <= 'z':
return name[:i] + string(name[i]-'a'+'A') + name[i+1:]
case name[i] >= 'A' && name[i] <= 'Z':
return name[:i] + string(name[i]-'A'+'a') + name[i+1:]
}
}
return ""
}
// InstallImagesEqual reports whether two recorded states match exactly,
// including permission mode. Commit planning uses it to decide whether a
// publication would change anything.
func InstallImagesEqual(left, right *InstallImage) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return left.SHA256 == right.SHA256 && left.Mode == right.Mode
}
func observeInstallImage(files *os.Root, relative string) (*InstallImage, error) {
info, err := files.Lstat(relative)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf(
"installation journal path %s is not a regular file",
relative,
)
}
file, err := files.Open(relative)
if err != nil {
return nil, err
}
hasher := sha256.New()
_, copyErr := io.Copy(hasher, file)
closeErr := file.Close()
if copyErr != nil || closeErr != nil {
return nil, errors.Join(copyErr, closeErr)
}
return &InstallImage{
SHA256: hex.EncodeToString(hasher.Sum(nil)),
Mode: uint32(info.Mode().Perm()),
}, nil
}
func checkInstallPreImage(
files *os.Root,
backup string,
entry InstallJournalEntry,
) error {
if entry.Before == nil {
return nil
}
info, err := files.Lstat(filepath.Join(backup, entry.Before.SHA256))
if err != nil {
return fmt.Errorf(
"installation pre-image %s for %s is unavailable: %w",
entry.Before.SHA256, entry.Path, err,
)
}
if !info.Mode().IsRegular() {
return fmt.Errorf(
"installation pre-image %s for %s is not a regular file",
entry.Before.SHA256, entry.Path,
)
}
return nil
}
func restoreInstallEntry(
files *os.Root,
backup string,
entry InstallJournalEntry,
) (resultErr error) {
relative := filepath.FromSlash(entry.Path)
if entry.Before == nil {
current, err := observeInstallImage(files, relative)
if err != nil {
return err
}
if classifyInstallEntry(current, entry.Before, entry.After) != installEntryApplied {
return fmt.Errorf(
"file %s changed during installation recovery; keep the journal and resolve the edit",
entry.Path,
)
}
return RemoveRoot(files, relative)
}
source, err := files.Open(filepath.Join(backup, entry.Before.SHA256))
if err != nil {
return fmt.Errorf("open installation pre-image %s: %w", entry.Path, err)
}
hasher := sha256.New()
check := func() error {
if actual := hex.EncodeToString(hasher.Sum(nil)); actual != entry.Before.SHA256 {
return fmt.Errorf(
"installation pre-image %s for %s is incomplete",
entry.Before.SHA256, entry.Path,
)
}
current, err := observeInstallImage(files, relative)
if err != nil {
return err
}
if classifyInstallEntry(current, entry.Before, entry.After) != installEntryApplied {
return fmt.Errorf(
"file %s changed during installation recovery; keep the journal and resolve the edit",
entry.Path,
)
}
return nil
}
copyErr := fsutil.WriteRoot(
files,
relative,
io.TeeReader(source, hasher),
fsutil.WriteOptions{
Mode: os.FileMode(entry.Before.Mode),
Replace: true,
Check: check,
},
)
closeErr := source.Close()
if copyErr != nil || closeErr != nil {
return errors.Join(copyErr, closeErr)
}
return nil
}
package workspacewrite
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/workspace"
"gopkg.in/yaml.v3"
)
// CheckInactive includes both declared runtime locations before replay can
// replace state. Callers name the operation that needs the pause so the error
// tells the user which command the server must stop for.
func CheckInactive(root string, during string) error {
directories := map[string]bool{root: true}
for _, name := range []string{document.ManifestFilename, document.LockFilename} {
if err := SafePath(root, name); err != nil {
return err
}
data, err := os.ReadFile(filepath.Join(root, name))
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
var state struct {
Server struct {
Directory string `yaml:"directory"`
} `yaml:"server"`
}
if err := yaml.Unmarshal(data, &state); err != nil {
return err
}
if state.Server.Directory != "" {
directory := filepath.FromSlash(state.Server.Directory)
if err := SafePath(root, directory); err != nil {
return err
}
directories[filepath.Join(root, directory)] = true
}
}
for directory := range directories {
if workspace.RunningAt(directory) {
return fmt.Errorf("stop the running Minecraft server before %s", during)
}
}
return nil
}
// Package java names the branded Java builds that a server may require. It
// is vocabulary only; runtime selection lives in package javaruntime.
package java
import (
"slices"
"strings"
)
// Distribution identifies a branded Java build by its foojay name. The
// unspecified distribution accepts any build, including unbranded builds from
// operating system package managers.
type Distribution string
const (
DistributionAny Distribution = ""
DistributionTemurin Distribution = "temurin"
DistributionZulu Distribution = "zulu"
DistributionCorretto Distribution = "corretto"
DistributionLiberica Distribution = "liberica"
DistributionMicrosoft Distribution = "microsoft"
DistributionOracle Distribution = "oracle"
DistributionOracleOpenJDK Distribution = "oracle_open_jdk"
DistributionGraalVM Distribution = "graalvm"
DistributionGraalVMCommunity Distribution = "graalvm_community"
DistributionSemeru Distribution = "semeru"
DistributionSapMachine Distribution = "sap_machine"
DistributionDragonwell Distribution = "dragonwell"
DistributionKona Distribution = "kona"
DistributionRedHat Distribution = "redhat"
DistributionJetBrains Distribution = "jetbrains"
)
// Distributions lists every distribution a requirement may name.
var Distributions = []Distribution{
DistributionTemurin,
DistributionZulu,
DistributionCorretto,
DistributionLiberica,
DistributionMicrosoft,
DistributionOracle,
DistributionOracleOpenJDK,
DistributionGraalVM,
DistributionGraalVMCommunity,
DistributionSemeru,
DistributionSapMachine,
DistributionDragonwell,
DistributionKona,
DistributionRedHat,
DistributionJetBrains,
}
// String returns the persisted key. The unspecified distribution renders as
// "any" for display only.
func (d Distribution) String() string {
if d == DistributionAny {
return "any"
}
return string(d)
}
// Title returns the human-facing distribution name.
func (d Distribution) Title() string {
switch d {
case DistributionAny:
return "Any"
case DistributionTemurin:
return "Eclipse Temurin"
case DistributionZulu:
return "Azul Zulu"
case DistributionCorretto:
return "Amazon Corretto"
case DistributionLiberica:
return "BellSoft Liberica"
case DistributionMicrosoft:
return "Microsoft Build of OpenJDK"
case DistributionOracle:
return "Oracle JDK"
case DistributionOracleOpenJDK:
return "Oracle OpenJDK"
case DistributionGraalVM:
return "Oracle GraalVM"
case DistributionGraalVMCommunity:
return "GraalVM Community"
case DistributionSemeru:
return "IBM Semeru"
case DistributionSapMachine:
return "SapMachine"
case DistributionDragonwell:
return "Alibaba Dragonwell"
case DistributionKona:
return "Tencent Kona"
case DistributionRedHat:
return "Red Hat build of OpenJDK"
case DistributionJetBrains:
return "JetBrains Runtime"
default:
return string(d)
}
}
// Accepts reports whether a build of distribution actual satisfies d. The
// unspecified distribution accepts every build; a named one accepts only
// itself, so an unbranded build never satisfies it.
func (d Distribution) Accepts(actual Distribution) bool {
return d == DistributionAny || d == actual
}
// ParseDistribution maps a distribution key to its typed value. Keys are
// case-insensitive, and "any" or the empty key mean the unspecified
// distribution.
func ParseDistribution(s string) (Distribution, bool) {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" || s == "any" {
return DistributionAny, true
}
if d := Distribution(s); slices.Contains(Distributions, d) {
return d, true
}
return DistributionAny, false
}
// Package javainstall installs a Java runtime through the most native method
// the host offers. The order is a Java version manager the user already runs,
// then the system package manager, then a vendor repository the user consented
// to, and last a per-user download. Lucy never changes the default Java of the
// user or the system.
package javainstall
import (
"cmp"
"context"
"slices"
"github.com/mclucy/lucy/java"
)
// Request names the Java runtime to install.
type Request struct {
Major int
Distribution java.Distribution
}
// Method names the mechanism that performs an installation.
type Method string
const (
MethodSDKMAN Method = "sdkman"
MethodMise Method = "mise"
MethodAsdf Method = "asdf"
MethodHomebrew Method = "brew"
MethodScoop Method = "scoop"
MethodWinget Method = "winget"
MethodApt Method = "apt"
MethodDnf Method = "dnf"
MethodZypper Method = "zypper"
MethodPacman Method = "pacman"
MethodApk Method = "apk"
MethodDownload Method = "download"
)
// Elevation names who obtains the rights a step needs.
type Elevation int
const (
// ElevationNone runs the step as the current user.
ElevationNone Elevation = iota
// ElevationRoot runs the step as root: directly when Lucy already runs as
// root, otherwise through sudo.
ElevationRoot
// ElevationSelf runs the step as the current user. The tool asks for rights
// or agreements itself, so the step needs an interactive terminal.
ElevationSelf
)
// Step is one command of a plan.
type Step struct {
Args []string
Elevation Elevation
// Input is fed to the standard input when set. An empty Input connects the
// terminal, so the tool can prompt.
Input string
}
// Repository is a vendor package repository that a plan adds before its steps.
type Repository struct {
// Name is shown to the user, for example "Eclipse Adoptium apt repository".
Name string
// URL is the repository location shown to the user.
URL string
// Key is the signing key that the repository files reference. It is nil
// when the package manager verifies the source itself.
Key *RepositoryKey
// Files are written as root after the key.
Files []RepositoryFile
// Steps run after the files, for example to refresh the package index.
Steps []Step
}
// RepositoryKey is an OpenPGP key that Lucy downloads and verifies against a
// pinned fingerprint before it writes the key.
type RepositoryKey struct {
URL string
// Fingerprint is the primary key fingerprint in upper-case hexadecimal
// without spaces.
Fingerprint string
// Path is where the verified key is written as root.
Path string
// Armored writes the key ASCII-armored; otherwise the key is written in
// binary form.
Armored bool
}
// RepositoryFile is one file a repository plan writes as root.
type RepositoryFile struct {
Path string
Content string
}
// Plan is one way to install the requested Java.
type Plan struct {
Method Method
// Package is the package, cask, candidate, or archive that the plan
// installs.
Package string
// Distribution is the build the plan installs. It is the unspecified
// distribution for unbranded builds.
Distribution java.Distribution
Major int
// Repository is added before the steps. A plan with a repository needs
// Policy.AllowRepository.
Repository *Repository
Steps []Step
// KeepDefault restores the system default Java captured before the steps,
// because package execution can move it before a later failure or
// cancellation. A step that exits from an unexpected signal blocks the
// restore because its child cannot be confirmed stopped.
KeepDefault bool
// root is the elevation fact captured while planning, so Commands renders
// the same sudo prefixes that execution uses.
root bool
// managedRoot is the per-user runtime directory captured while planning a
// download, so Commands and execution name the same destination.
managedRoot string
download *download
}
// SkipReason tells why a plan that could install the request is not usable.
type SkipReason int
const (
// SkipNeedsRoot means the plan needs root and Lucy can neither run as root
// nor use sudo.
SkipNeedsRoot SkipReason = iota + 1
// SkipNeedsTerminal means the tool prompts for rights or agreements and no
// interactive terminal is available.
SkipNeedsTerminal
// SkipNeedsRepositoryConsent means the plan adds a vendor repository and
// the user did not consent.
SkipNeedsRepositoryConsent
)
// Skipped is a plan that could install the request but is not usable on this
// run.
type Skipped struct {
Plan Plan
Reason SkipReason
}
// manager is one package manager or Java version manager.
// Managers are stateless; the Host carries the invocation's facts.
type manager interface {
method() Method
// system reports that the manager installs for the whole system rather
// than for the current user.
system() bool
// ownsJava reports that the manager already installed a Java runtime on
// this host.
ownsJava(ctx context.Context, host *Host) bool
// plans returns the ways the manager can install req. It returns nil when
// the manager is absent or offers no matching package.
plans(ctx context.Context, req Request, host *Host) ([]Plan, error)
}
func managers() []manager {
return []manager{
sdkman{},
mise{},
asdf{},
homebrew{},
scoop{},
apt{},
dnf{},
zypper{},
pacman{},
apk{},
winget{},
}
}
const (
tierOwningUser = iota
tierUser
tierSystem
tierRepository
tierDownload
)
// Plans lists the usable ways to install req, most native first, and the
// plans that this run cannot use. A manager that fails to answer is skipped,
// because another method can still install the request.
func (h *Host) Plans(
ctx context.Context,
req Request,
policy Policy,
) ([]Plan, []Skipped, error) {
type ranked struct {
plan Plan
tier int
}
var candidates []ranked
for _, m := range managers() {
plans, err := m.plans(ctx, req, h)
if ctx.Err() != nil {
return nil, nil, ctx.Err()
}
if err != nil {
h.logger.Debug(
"java install method unavailable",
"method", m.method(),
"reason", err,
)
continue
}
tier := tierUser
switch {
case m.system():
tier = tierSystem
case m.ownsJava(ctx, h):
tier = tierOwningUser
}
for _, plan := range plans {
plan.root = h.isRoot()
planTier := tier
if plan.Repository != nil {
planTier = tierRepository
}
candidates = append(candidates, ranked{plan: plan, tier: planTier})
}
}
download, err := h.downloadPlan(ctx, req)
if err != nil {
if ctx.Err() != nil {
return nil, nil, ctx.Err()
}
h.logger.Debug("java download unavailable", "reason", err)
} else if download != nil {
candidates = append(candidates, ranked{plan: *download, tier: tierDownload})
}
slices.SortStableFunc(candidates, func(a, b ranked) int {
return cmp.Compare(a.tier, b.tier)
})
var usable []Plan
var skipped []Skipped
for _, candidate := range candidates {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
plan := candidate.plan
if plan.Major != req.Major || !req.Distribution.Accepts(plan.Distribution) {
h.logger.Debug(
"java install plan rejected",
"method", plan.Method,
"package", plan.Package,
"reason", "plan does not install the requested Java",
)
continue
}
reason, err := skipReason(ctx, plan, policy, h)
if err != nil {
return nil, nil, err
}
if reason != 0 {
skipped = append(skipped, Skipped{Plan: plan, Reason: reason})
continue
}
usable = append(usable, plan)
}
return usable, skipped, ctx.Err()
}
func skipReason(
ctx context.Context,
plan Plan,
policy Policy,
host *Host,
) (SkipReason, error) {
if plan.Repository != nil && !policy.AllowRepository {
return SkipNeedsRepositoryConsent, nil
}
for _, step := range plan.allSteps() {
switch step.Elevation {
case ElevationRoot:
available, err := host.rootAvailable(ctx, policy)
if err != nil {
return 0, err
}
if !available {
return SkipNeedsRoot, nil
}
case ElevationSelf:
if !policy.Interactive {
return SkipNeedsTerminal, nil
}
}
}
if plan.Repository != nil && (plan.Repository.Key != nil || len(plan.Repository.Files) > 0) {
available, err := host.rootAvailable(ctx, policy)
if err != nil {
return 0, err
}
if !available {
return SkipNeedsRoot, nil
}
}
return 0, nil
}
// allSteps returns the repository steps followed by the install steps.
func (p Plan) allSteps() []Step {
if p.Repository == nil {
return p.Steps
}
return append(slices.Clone(p.Repository.Steps), p.Steps...)
}
package javainstall
import (
"context"
"io"
"path/filepath"
"time"
)
// restoreTimeout bounds a best-effort restore. Package execution may have ended
// in failure or cancellation, so restoration detaches from that cancellation;
// the bound keeps a stuck package tool or credential prompt from blocking exit.
const restoreTimeout = 30 * time.Second
// systemDefaultJava returns the resolved target of /usr/bin/java, or an empty
// string when the host has no default Java.
func systemDefaultJava(host *Host) string {
if host.goos != "linux" {
return ""
}
target, err := host.system.EvalSymlinks("/usr/bin/java")
if err != nil {
return ""
}
return target
}
// restoreSystemDefault points the java alternative back at previous when a
// package moved it. It does nothing when the default did not change. A restore
// runs even when the package execution failed or was canceled, so it detaches
// from that cancellation and is bounded by restoreTimeout. It uses the
// invocation's elevation facts.
func restoreSystemDefault(
ctx context.Context,
previous string,
policy Policy,
host *Host,
input io.Reader,
out io.Writer,
) error {
if host.goos != "linux" || previous == "" {
return nil
}
current := systemDefaultJava(host)
if filepath.Clean(current) == filepath.Clean(previous) {
return nil
}
host.logger.Info("restoring system default java", "path", previous)
restoreCtx, cancel := context.WithTimeout(
context.WithoutCancel(ctx),
restoreTimeout,
)
defer cancel()
return runStep(
restoreCtx,
Step{
Args: []string{
"update-alternatives",
"--set",
"java",
previous,
},
Elevation: ElevationRoot,
},
policy,
host,
input,
out,
)
}
package javainstall
import (
"context"
"fmt"
"strings"
"github.com/mclucy/lucy/java"
)
// apk installs Java from the configured APK sources.
type apk struct{}
func (apk) method() Method { return MethodApk }
func (apk) system() bool { return true }
func (apk) ownsJava(context.Context, *Host) bool { return false }
func (apk) plans(ctx context.Context, req Request, host *Host) ([]Plan, error) {
if req.Major <= 0 ||
req.Distribution != java.DistributionAny ||
!host.systemToolAvailable("apk") {
return nil, nil
}
pkg := fmt.Sprintf("openjdk%d-jre-headless", req.Major)
if !apkPackageAvailable(ctx, host, pkg) {
return nil, nil
}
return []Plan{
{
Method: MethodApk,
Package: pkg,
Distribution: java.DistributionAny,
Major: req.Major,
Steps: []Step{
{
Args: []string{
"apk",
"add",
"--no-cache",
pkg,
},
Elevation: ElevationRoot,
},
},
},
}, nil
}
// apkPackageAvailable reports whether the configured sources offer pkg. The
// probe never changes the package index.
func apkPackageAvailable(
ctx context.Context,
host *Host,
pkg string,
) bool {
output, err := host.systemProbe(ctx, "apk", "search", "--exact", pkg)
if err != nil {
host.logger.Debug("apk package probe failed", "package", pkg, "reason", err)
return false
}
for line := range strings.Lines(string(output)) {
name := strings.TrimSpace(line)
if name == pkg || strings.HasPrefix(name, pkg+"-") {
return true
}
}
return false
}
package javainstall
import (
"context"
"fmt"
"strings"
"github.com/mclucy/lucy/java"
)
// apt installs Java from the configured APT sources.
type apt struct{}
func (apt) method() Method { return MethodApt }
func (apt) system() bool { return true }
func (apt) ownsJava(context.Context, *Host) bool { return false }
func (apt) plans(ctx context.Context, req Request, host *Host) ([]Plan, error) {
if req.Major <= 0 || !host.systemToolAvailable("apt-get", "apt-cache") {
return nil, nil
}
if req.Distribution == java.DistributionAny {
pkg := fmt.Sprintf("openjdk-%d-jre-headless", req.Major)
if !aptPackageAvailable(ctx, host, pkg) {
return nil, nil
}
return []Plan{
aptPlan(pkg, java.DistributionAny, req.Major, nil),
}, nil
}
packages, ok := aptBrandedPackages(req.Distribution, req.Major)
if !ok {
return nil, nil
}
for _, pkg := range packages {
if aptPackageAvailable(ctx, host, pkg) {
return []Plan{
aptPlan(pkg, req.Distribution, req.Major, nil),
}, nil
}
}
repository := aptRepository(req.Distribution, host)
if repository == nil {
return nil, nil
}
return []Plan{
aptPlan(packages[0], req.Distribution, req.Major, repository),
}, nil
}
func aptPlan(
pkg string,
distribution java.Distribution,
major int,
repository *Repository,
) Plan {
return Plan{
Method: MethodApt,
Package: pkg,
Distribution: distribution,
Major: major,
Repository: repository,
Steps: []Step{
{
Args: []string{
"env",
"DEBIAN_FRONTEND=noninteractive",
"apt-get",
"install",
"-y",
"--no-install-recommends",
pkg,
},
Elevation: ElevationRoot,
},
},
KeepDefault: true,
}
}
// aptPackageAvailable reports whether the configured sources offer pkg. The
// probe never changes the package index.
func aptPackageAvailable(
ctx context.Context,
host *Host,
pkg string,
) bool {
output, err := host.systemProbe(ctx, "apt-cache", "policy", pkg)
if err != nil {
host.logger.Debug("apt package probe failed", "package", pkg, "reason", err)
return false
}
for line := range strings.Lines(string(output)) {
candidate, ok := strings.CutPrefix(
strings.TrimSpace(line),
"Candidate:",
)
if !ok {
continue
}
candidate = strings.TrimSpace(candidate)
return candidate != "" && candidate != "(none)"
}
return false
}
// aptBrandedPackages returns the package names of a vendor build, most
// preferred first, and whether APT can provide that build.
func aptBrandedPackages(
distribution java.Distribution,
major int,
) ([]string, bool) {
switch distribution {
case java.DistributionTemurin:
return []string{
fmt.Sprintf("temurin-%d-jre", major),
fmt.Sprintf("temurin-%d-jdk", major),
}, true
case java.DistributionZulu:
return []string{
fmt.Sprintf("zulu%d-jre-headless", major),
fmt.Sprintf("zulu%d-jdk-headless", major),
}, true
case java.DistributionCorretto:
return []string{
fmt.Sprintf("java-%d-amazon-corretto-jdk", major),
}, true
default:
return nil, false
}
}
// aptRepository returns the vendor APT source for a build. It returns nil
// when the vendor documents no source for this host.
func aptRepository(distribution java.Distribution, host *Host) *Repository {
switch distribution {
case java.DistributionTemurin:
release := host.systemRelease()
suite := release["VERSION_CODENAME"]
if suite == "" {
suite = release["UBUNTU_CODENAME"]
}
if suite == "" {
return nil
}
return aptVendorRepository(
"Eclipse Adoptium apt repository",
"https://packages.adoptium.net/artifactory/deb",
"adoptium",
"3B04D753C9050D9A5D343F39843C48A565F8F04B",
"https://packages.adoptium.net/artifactory/api/gpg/key/public",
suite,
)
case java.DistributionZulu:
return aptVendorRepository(
"Azul Zulu apt repository",
"https://repos.azul.com/zulu/deb",
"azul",
"27BC0C8CB3D81623F59BDADCB1998361219BD9C9",
"https://repos.azul.com/azul-repo.key",
"stable",
)
case java.DistributionCorretto:
return aptVendorRepository(
"Amazon Corretto apt repository",
"https://apt.corretto.aws",
"corretto",
"6DC3636DAE534049C8B94623A122542AB04F24E3",
"https://apt.corretto.aws/corretto.key",
"stable",
)
default:
return nil
}
}
func aptVendorRepository(
name string,
repositoryURL string,
vendor string,
fingerprint string,
keyURL string,
suite string,
) *Repository {
keyPath := "/etc/apt/keyrings/" + vendor + ".asc"
return &Repository{
Name: name,
URL: repositoryURL,
Key: &RepositoryKey{
URL: keyURL,
Fingerprint: fingerprint,
Path: keyPath,
Armored: true,
},
Files: []RepositoryFile{
{
Path: "/etc/apt/sources.list.d/" + vendor + ".list",
Content: fmt.Sprintf(
"deb [signed-by=%s] %s %s main\n",
keyPath,
repositoryURL,
suite,
),
},
},
Steps: []Step{
{
Args: []string{"apt-get", "update"},
Elevation: ElevationRoot,
},
},
}
}
package javainstall
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/mclucy/lucy/java"
)
// asdfVendors maps each distribution to the vendor prefix of its asdf-java
// version. asdf-java has no Red Hat build.
var asdfVendors = map[java.Distribution]string{
java.DistributionTemurin: "temurin",
java.DistributionZulu: "zulu",
java.DistributionCorretto: "corretto",
java.DistributionLiberica: "liberica",
java.DistributionMicrosoft: "microsoft",
java.DistributionOracle: "oracle",
java.DistributionOracleOpenJDK: "openjdk",
java.DistributionGraalVM: "oracle-graalvm",
java.DistributionGraalVMCommunity: "graalvm-community",
java.DistributionSemeru: "semeru-openj9",
java.DistributionSapMachine: "sapmachine",
java.DistributionDragonwell: "dragonwell",
java.DistributionKona: "kona",
java.DistributionJetBrains: "jetbrains",
}
type asdf struct{}
func (asdf) method() Method { return MethodAsdf }
func (asdf) system() bool { return false }
func (asdf) ownsJava(_ context.Context, host *Host) bool {
dir := asdfDataDir(host)
if dir == "" {
return false
}
entries, err := os.ReadDir(filepath.Join(dir, "installs", "java"))
return err == nil && len(entries) > 0
}
func (asdf) plans(ctx context.Context, req Request, host *Host) ([]Plan, error) {
if _, err := host.lookPath("asdf"); err != nil {
return nil, nil
}
plugins, err := asdfCommand(ctx, host, "plugin", "list")
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
host.logger.Debug("asdf plugins unavailable", "reason", err)
return nil, nil
}
if !asdfHasJavaPlugin(plugins) {
return nil, nil
}
prefix, distribution, ok := asdfVendor(req.Distribution)
if !ok {
return nil, nil
}
spec := prefix + "-" + strconv.Itoa(req.Major)
versions, err := asdfCommand(ctx, host, "list", "all", "java", spec)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
host.logger.Debug("asdf java versions unavailable", "spec", spec, "reason", err)
return nil, nil
}
if strings.TrimSpace(versions) == "" {
return nil, nil
}
return []Plan{{
Method: MethodAsdf,
Package: "java@" + spec,
Distribution: distribution,
Major: req.Major,
Steps: []Step{{
Args: []string{"asdf", "install", "java", "latest:" + spec},
Elevation: ElevationNone,
}},
}}, nil
}
// asdfVendor returns the vendor prefix for dist. The unspecified distribution
// installs Temurin, the plugin's default build.
func asdfVendor(dist java.Distribution) (string, java.Distribution, bool) {
if dist == java.DistributionAny {
return "temurin", java.DistributionTemurin, true
}
prefix, ok := asdfVendors[dist]
return prefix, dist, ok
}
func asdfDataDir(host *Host) string {
if dir := host.getenv("ASDF_DATA_DIR"); dir != "" {
return dir
}
home, err := host.userHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".asdf")
}
// asdfHasJavaPlugin reports whether the java plugin is installed. asdf prints
// one plugin name per line.
func asdfHasJavaPlugin(output string) bool {
for _, line := range strings.Split(output, "\n") {
if strings.TrimSpace(line) == "java" {
return true
}
}
return false
}
// asdfCommand runs a read-only asdf query with a bounded wait.
func asdfCommand(
ctx context.Context,
host *Host,
args ...string,
) (string, error) {
output, err := host.probe(ctx, 20*time.Second, nil, "asdf", args...)
return string(output), err
}
package javainstall
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
"github.com/mclucy/lucy/java"
)
// homebrewCasks maps a branded distribution to the base token of its macOS
// cask. Homebrew publishes no cask for the remaining distributions.
var homebrewCasks = map[java.Distribution]string{
java.DistributionTemurin: "temurin",
java.DistributionZulu: "zulu",
java.DistributionCorretto: "corretto",
java.DistributionMicrosoft: "microsoft-openjdk",
java.DistributionOracle: "oracle-jdk",
java.DistributionGraalVM: "graalvm-jdk",
java.DistributionSapMachine: "sapmachine-jdk",
java.DistributionSemeru: "semeru-jdk-open",
}
type homebrew struct{}
func (homebrew) method() Method { return MethodHomebrew }
func (homebrew) system() bool { return false }
func (homebrew) ownsJava(ctx context.Context, host *Host) bool {
if host.isRoot() {
return false
}
if _, err := host.lookPath("brew"); err != nil {
return false
}
formulae, err := homebrewList(ctx, host, "--formula")
if err == nil {
for _, token := range strings.Fields(formulae) {
if strings.HasPrefix(token, "openjdk") {
return true
}
}
}
casks, err := homebrewList(ctx, host, "--cask")
if err != nil {
return false
}
for _, token := range strings.Fields(casks) {
if homebrewJavaCask(token) {
return true
}
}
return false
}
func (homebrew) plans(ctx context.Context, req Request, host *Host) ([]Plan, error) {
if host.goos != "darwin" && host.goos != "linux" {
return nil, nil
}
if host.isRoot() {
return nil, nil
}
if _, err := host.lookPath("brew"); err != nil {
return nil, nil
}
if req.Distribution == java.DistributionAny {
token, ok := homebrewFormula(ctx, host, req.Major)
if !ok {
return nil, nil
}
return []Plan{homebrewPlan(token, req.Major, java.DistributionAny, false)}, nil
}
if host.goos != "darwin" {
return nil, nil
}
base, ok := homebrewCasks[req.Distribution]
if !ok {
return nil, nil
}
token, ok := homebrewCask(ctx, host, base, req.Major)
if !ok {
return nil, nil
}
return []Plan{homebrewPlan(token, req.Major, req.Distribution, true)}, nil
}
// homebrewFormula returns the formula that installs the requested major. The
// versioned formula is preferred; the unversioned openjdk formula qualifies
// only when its current major matches.
func homebrewFormula(ctx context.Context, host *Host, major int) (string, bool) {
token := "openjdk@" + strconv.Itoa(major)
if version, ok := homebrewBottledFormulaVersion(ctx, host, token); ok && homebrewMajor(version) == major {
return token, true
}
version, ok := homebrewBottledFormulaVersion(ctx, host, "openjdk")
if !ok || homebrewMajor(version) != major {
return "", false
}
return "openjdk", true
}
// homebrewCask returns the cask that installs the requested major. A versioned
// cask is preferred; the base cask qualifies only when its current major
// matches.
func homebrewCask(ctx context.Context, host *Host, base string, major int) (string, bool) {
for _, token := range []string{base + "@" + strconv.Itoa(major), base} {
version, ok := homebrewCaskVersion(ctx, host, token)
if ok && homebrewMajor(version) == major {
return token, true
}
}
return "", false
}
// homebrewBottledFormulaVersion returns the stable version of a formula that
// has a bottle. A formula without one compiles the JDK from source, which
// takes long and is not a native install.
func homebrewBottledFormulaVersion(ctx context.Context, host *Host, token string) (string, bool) {
output, err := homebrewInfo(ctx, host, "--formula", token)
if err != nil {
if ctx.Err() == nil {
host.logger.Debug("homebrew formula info unavailable", "formula", token, "reason", err)
}
return "", false
}
var info struct {
Formulae []struct {
Versions struct {
Stable string `json:"stable"`
} `json:"versions"`
Bottle struct {
Stable struct {
Files map[string]json.RawMessage `json:"files"`
} `json:"stable"`
} `json:"bottle"`
} `json:"formulae"`
}
if err := json.Unmarshal(output, &info); err != nil || len(info.Formulae) == 0 {
return "", false
}
formula := info.Formulae[0]
if len(formula.Bottle.Stable.Files) == 0 {
host.logger.Debug(
"homebrew formula skipped",
"formula", token,
"reason", "no bottle, so it would build from source",
)
return "", false
}
return formula.Versions.Stable, true
}
func homebrewCaskVersion(ctx context.Context, host *Host, token string) (string, bool) {
output, err := homebrewInfo(ctx, host, "--cask", token)
if err != nil {
if ctx.Err() == nil {
host.logger.Debug("homebrew cask info unavailable", "cask", token, "reason", err)
}
return "", false
}
var info struct {
Casks []struct {
Version string `json:"version"`
} `json:"casks"`
}
if err := json.Unmarshal(output, &info); err != nil || len(info.Casks) == 0 {
return "", false
}
return info.Casks[0].Version, true
}
// homebrewInfo reads local Homebrew metadata without updating taps.
func homebrewInfo(ctx context.Context, host *Host, kind, token string) ([]byte, error) {
environment := append(host.environ(), "HOMEBREW_NO_AUTO_UPDATE=1")
return host.probe(ctx, 30*time.Second, environment, "brew", "info", "--json=v2", kind, token)
}
func homebrewList(ctx context.Context, host *Host, kind string) (string, error) {
output, err := host.probe(ctx, 20*time.Second, nil, "brew", "list", kind)
return string(output), err
}
// homebrewMajor returns the leading major of a formula or cask version, which
// may carry a revision after a comma. Java 8 builds use the legacy 1.8 scheme.
func homebrewMajor(version string) int {
version = strings.TrimPrefix(version, "1.")
end := 0
for end < len(version) && version[end] >= '0' && version[end] <= '9' {
end++
}
if end == 0 {
return 0
}
major, err := strconv.Atoi(version[:end])
if err != nil {
return 0
}
return major
}
func homebrewJavaCask(token string) bool {
for _, base := range []string{
"temurin",
"zulu",
"corretto",
"microsoft-openjdk",
"oracle-jdk",
"graalvm-jdk",
"sapmachine-jdk",
"semeru-jdk-open",
} {
if token == base || strings.HasPrefix(token, base+"@") {
return true
}
}
return false
}
func homebrewPlan(token string, major int, distribution java.Distribution, cask bool) Plan {
args := []string{"brew", "install"}
elevation := ElevationNone
if cask {
args = append(args, "--cask")
// The .pkg installer asks for the password itself.
elevation = ElevationSelf
}
args = append(args, token)
return Plan{
Method: MethodHomebrew,
Package: token,
Distribution: distribution,
Major: major,
Steps: []Step{{
Args: args,
Elevation: elevation,
}},
}
}
package javainstall
import (
"context"
"fmt"
"strings"
"github.com/mclucy/lucy/java"
)
// dnf installs Java from the configured DNF sources.
type dnf struct{}
func (dnf) method() Method { return MethodDnf }
func (dnf) system() bool { return true }
func (dnf) ownsJava(context.Context, *Host) bool { return false }
func (dnf) plans(ctx context.Context, req Request, host *Host) ([]Plan, error) {
if req.Major <= 0 || !host.systemToolAvailable("dnf") {
return nil, nil
}
if req.Distribution == java.DistributionAny {
pkg := fmt.Sprintf("java-%d-openjdk-headless", req.Major)
if !dnfPackageAvailable(ctx, host, pkg) {
return nil, nil
}
return []Plan{
dnfPlan(pkg, java.DistributionAny, req.Major, nil),
}, nil
}
packages, ok := dnfBrandedPackages(req.Distribution, req.Major)
if !ok {
return nil, nil
}
for _, pkg := range packages {
if dnfPackageAvailable(ctx, host, pkg) {
return []Plan{
dnfPlan(pkg, req.Distribution, req.Major, nil),
}, nil
}
}
repository := dnfRepository(req.Distribution, host)
if repository == nil {
return nil, nil
}
return []Plan{
dnfPlan(packages[0], req.Distribution, req.Major, repository),
}, nil
}
func dnfPlan(
pkg string,
distribution java.Distribution,
major int,
repository *Repository,
) Plan {
return Plan{
Method: MethodDnf,
Package: pkg,
Distribution: distribution,
Major: major,
Repository: repository,
Steps: []Step{
{
Args: []string{"dnf", "install", "-y", pkg},
Elevation: ElevationRoot,
},
},
KeepDefault: true,
}
}
// dnfPackageAvailable reports whether the configured sources offer pkg. The
// probe never installs or changes repository data.
func dnfPackageAvailable(
ctx context.Context,
host *Host,
pkg string,
) bool {
output, err := host.systemProbe(ctx, "dnf", "--quiet", "list", "--available", pkg)
if err != nil {
host.logger.Debug("dnf package probe failed", "package", pkg, "reason", err)
return false
}
for line := range strings.Lines(string(output)) {
name, _, _ := strings.Cut(strings.TrimSpace(line), ".")
if name == pkg {
return true
}
}
return false
}
// dnfBrandedPackages returns the package names of a vendor build, most
// preferred first, and whether DNF can provide that build.
func dnfBrandedPackages(
distribution java.Distribution,
major int,
) ([]string, bool) {
switch distribution {
case java.DistributionTemurin:
return []string{
fmt.Sprintf("temurin-%d-jre", major),
fmt.Sprintf("temurin-%d-jdk", major),
}, true
case java.DistributionZulu:
return []string{
fmt.Sprintf("zulu%d-jre-headless", major),
fmt.Sprintf("zulu%d-jdk-headless", major),
}, true
case java.DistributionCorretto:
return []string{
fmt.Sprintf("java-%d-amazon-corretto-devel", major),
}, true
default:
return nil, false
}
}
// dnfRepository returns the vendor DNF source for a build. It returns nil when
// the vendor documents no source for this host.
func dnfRepository(distribution java.Distribution, host *Host) *Repository {
switch distribution {
case java.DistributionTemurin:
repositoryURL := adoptiumRPMRepository(host)
if repositoryURL == "" {
return nil
}
return dnfVendorRepository(
"Eclipse Adoptium RPM repository",
repositoryURL,
"adoptium",
"3B04D753C9050D9A5D343F39843C48A565F8F04B",
"https://packages.adoptium.net/artifactory/api/gpg/key/public",
"Eclipse Adoptium",
)
case java.DistributionCorretto:
return dnfVendorRepository(
"Amazon Corretto RPM repository",
"https://yum.corretto.aws/$basearch",
"corretto",
"6DC3636DAE534049C8B94623A122542AB04F24E3",
"https://yum.corretto.aws/corretto.key",
"Amazon Corretto",
)
default:
return nil
}
}
// adoptiumRPMRepository returns the Adoptium RPM base URL for this host. The
// vendor publishes a directory per distribution and major version.
func adoptiumRPMRepository(host *Host) string {
release := host.systemRelease()
switch release["ID"] {
case "centos", "rhel", "fedora":
default:
return ""
}
major := systemVersionMajor(release["VERSION_ID"])
arch := systemRPMArch(host.goarch)
if major == "" || arch == "" {
return ""
}
return fmt.Sprintf(
"https://packages.adoptium.net/artifactory/rpm/%s/%s/%s",
release["ID"],
major,
arch,
)
}
func dnfVendorRepository(
name string,
repositoryURL string,
vendor string,
fingerprint string,
keyURL string,
repositoryName string,
) *Repository {
keyPath := "/etc/pki/rpm-gpg/" + vendor + ".asc"
content := fmt.Sprintf(
"[%s]\nname=%s\nbaseurl=%s\nenabled=1\ngpgcheck=1\ngpgkey=file://%s\n",
vendor,
repositoryName,
repositoryURL,
keyPath,
)
return &Repository{
Name: name,
URL: repositoryURL,
Key: &RepositoryKey{
URL: keyURL,
Fingerprint: fingerprint,
Path: keyPath,
Armored: true,
},
Files: []RepositoryFile{
{
Path: "/etc/yum.repos.d/" + vendor + ".repo",
Content: content,
},
},
Steps: []Step{
{
Args: []string{"rpm", "--import", keyPath},
Elevation: ElevationRoot,
},
},
}
}
package javainstall
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json/v2"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/mclucy/lucy/java"
)
const (
foojayPackagesURL = "https://api.foojay.io/disco/v3.0/packages"
foojayIDURL = "https://api.foojay.io/disco/v3.0/ids/"
// The package metadata only changes with releases, so a short TTL keeps
// the latest build current without a network read on every plan.
foojayCacheTTL = 12 * time.Hour
)
type download struct {
url string
filename string
checksum string
archive string
size int64
javaVersion string
distribution java.Distribution
destination string
}
type foojayPackages struct {
Result []foojayPackage `json:"result"`
}
// foojayPackage is one package of the package list and of the detailed
// /ids response. The detailed response carries the download URI and checksum.
type foojayPackage struct {
ID string `json:"id"`
ArchiveType string `json:"archive_type"`
Distribution string `json:"distribution"`
Filename string `json:"filename"`
JavaVersion string `json:"java_version"`
Checksum string `json:"checksum"`
ChecksumType string `json:"checksum_type"`
ChecksumURI string `json:"checksum_uri"`
DirectDownloadURI string `json:"direct_download_uri"`
Size int64 `json:"size"`
}
// anyDistributionOrder lists the distributions that a request for any
// distribution tries in turn. foojay has no Temurin build for some old majors
// and platforms, such as Java 8 on Apple silicon.
var anyDistributionOrder = []java.Distribution{
java.DistributionTemurin,
java.DistributionZulu,
java.DistributionCorretto,
java.DistributionLiberica,
}
func (h *Host) downloadPlan(ctx context.Context, req Request) (*Plan, error) {
architecture, ok := downloadArchitecture(h.goarch)
if !ok {
return nil, nil
}
operatingSystem, ok := downloadOperatingSystem(h.goos)
if !ok {
return nil, nil
}
distributions := []java.Distribution{req.Distribution}
if req.Distribution == java.DistributionAny {
distributions = anyDistributionOrder
}
for _, distribution := range distributions {
plan, err := h.downloadPlanFor(ctx, req.Major, distribution, architecture, operatingSystem)
if err != nil {
return nil, err
}
if plan != nil {
return plan, nil
}
}
return nil, nil
}
// downloadPlanFor finds a verifiable download of one distribution. A runtime
// without the compiler is enough to run a server, so it asks for a JRE first.
func (h *Host) downloadPlanFor(
ctx context.Context,
major int,
distribution java.Distribution,
architecture string,
operatingSystem string,
) (*Plan, error) {
for _, packageType := range []string{"jre", "jdk"} {
packages, err := h.fetchDownloadPackages(
ctx,
major,
distribution,
architecture,
operatingSystem,
packageType,
)
if err != nil {
return nil, err
}
for _, pkg := range packages {
if pkg.ID == "" {
continue
}
build, err := h.fetchDownloadBuild(ctx, pkg.ID)
if err != nil {
h.logger.Debug(
"java download package unavailable",
"id", pkg.ID,
"reason", err,
)
continue
}
if build.Checksum == "" && build.ChecksumURI != "" {
build.Checksum, err = h.fetchChecksumFile(ctx, build.ChecksumURI)
if err != nil {
h.logger.Debug(
"java download checksum unavailable",
"id", pkg.ID,
"reason", err,
)
continue
}
}
download, ok := newDownload(pkg, build, distribution, major)
if !ok {
continue
}
root, err := h.managedRoot()
if err != nil {
return nil, fmt.Errorf("find the Java runtime directory: %w", err)
}
return &Plan{
Method: MethodDownload,
Package: download.filename,
Distribution: distribution,
Major: major,
root: h.isRoot(),
managedRoot: root,
download: download,
}, nil
}
}
return nil, nil
}
// newDownload combines the package list entry with the detailed build entry.
// It reports false when the build cannot be verified or downloaded.
func newDownload(
pkg foojayPackage,
build foojayPackage,
distribution java.Distribution,
major int,
) (*download, bool) {
checksumType := strings.ToLower(strings.TrimSpace(build.ChecksumType))
if checksumType == "" {
checksumType = strings.ToLower(strings.TrimSpace(pkg.ChecksumType))
}
if checksumType != "sha256" {
return nil, false
}
checksum := strings.TrimSpace(build.Checksum)
if checksum == "" {
checksum = strings.TrimSpace(pkg.Checksum)
}
if checksum == "" {
return nil, false
}
address := strings.TrimSpace(build.DirectDownloadURI)
if address == "" {
address = strings.TrimSpace(pkg.DirectDownloadURI)
}
if address == "" {
return nil, false
}
filename := build.Filename
if filename == "" {
filename = pkg.Filename
}
if filename == "" {
return nil, false
}
archive := pkg.ArchiveType
if archive == "" {
archive = downloadArchiveType(filename)
}
if archive != "tar.gz" && archive != "zip" {
return nil, false
}
version := pkg.JavaVersion
if version == "" {
version = build.JavaVersion
}
if version == "" {
version = strconv.Itoa(major)
}
return &download{
url: address,
filename: filename,
checksum: strings.ToLower(checksum),
archive: archive,
size: pkg.Size,
javaVersion: version,
distribution: distribution,
destination: downloadDirectoryName(distribution, version),
}, true
}
func (h *Host) fetchDownloadPackages(
ctx context.Context,
major int,
distribution java.Distribution,
architecture string,
operatingSystem string,
packageType string,
) ([]foojayPackage, error) {
query := url.Values{}
query.Set("version", strconv.Itoa(major))
query.Set("distribution", string(distribution))
query.Set("architecture", architecture)
query.Set("operating_system", operatingSystem)
musl := operatingSystem == "linux" && h.usesMusl()
query.Set("lib_c_type", downloadLibC(musl, operatingSystem))
query.Add("archive_type", "tar.gz")
query.Add("archive_type", "zip")
query.Set("package_type", packageType)
query.Set("release_status", "ga")
query.Set("latest", "available")
query.Set("javafx_bundled", "false")
query.Set("directly_downloadable", "true")
return h.fetchFoojay(ctx, foojayPackagesURL+"?"+query.Encode())
}
func (h *Host) fetchDownloadBuild(ctx context.Context, id string) (foojayPackage, error) {
builds, err := h.fetchFoojay(ctx, foojayIDURL+url.PathEscape(id))
if err != nil {
return foojayPackage{}, err
}
if len(builds) == 0 {
return foojayPackage{}, errors.New("download package has no build")
}
return builds[0], nil
}
func (h *Host) fetchFoojay(
ctx context.Context,
address string,
) ([]foojayPackage, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
data, err := h.fetchMetadata(ctx, address, foojayCacheTTL)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", address, err)
}
var packages foojayPackages
if err := json.Unmarshal(data, &packages); err != nil {
return nil, fmt.Errorf("parse %s: %w", address, err)
}
return packages.Result, nil
}
// fetchChecksumFile reads the sha256 digest from a checksum file that a
// vendor publishes next to its archive. The file is "<digest>" or
// "<digest> <filename>". The digest then comes from the same origin as the
// archive, so it detects a damaged download but not a replaced release.
func (h *Host) fetchChecksumFile(ctx context.Context, address string) (string, error) {
if !strings.HasPrefix(address, "https://") {
return "", fmt.Errorf("checksum file %s is not served over HTTPS", address)
}
if err := ctx.Err(); err != nil {
return "", err
}
data, err := h.fetchMetadata(ctx, address, foojayCacheTTL)
if err != nil {
return "", fmt.Errorf("fetch %s: %w", address, err)
}
fields := strings.Fields(string(data))
if len(fields) == 0 {
return "", fmt.Errorf("checksum file %s is empty", address)
}
digest, err := hex.DecodeString(fields[0])
if err != nil || len(digest) != sha256.Size {
return "", fmt.Errorf("checksum file %s has no sha256 digest", address)
}
return strings.ToLower(fields[0]), nil
}
func downloadArchitecture(goarch string) (string, bool) {
switch goarch {
case "amd64":
return "x64", true
case "arm64":
return "aarch64", true
default:
return "", false
}
}
func downloadOperatingSystem(goos string) (string, bool) {
switch goos {
case "linux":
return "linux", true
case "darwin":
return "macos", true
case "windows":
return "windows", true
default:
return "", false
}
}
// downloadLibC returns the libc that the download must match. Windows has no
// libc, but the API still expects a value.
func downloadLibC(musl bool, operatingSystem string) string {
switch operatingSystem {
case "macos":
return "libc"
case "windows":
return "c_std_lib"
default:
if musl {
return "musl"
}
return "glibc"
}
}
func downloadArchiveType(filename string) string {
lower := strings.ToLower(filename)
switch {
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
return "tar.gz"
case strings.HasSuffix(lower, ".zip"):
return "zip"
default:
return ""
}
}
func downloadDirectoryName(
distribution java.Distribution,
version string,
) string {
name := string(distribution) + "-" + version
var builder strings.Builder
for _, character := range name {
switch {
case character >= 'a' && character <= 'z',
character >= 'A' && character <= 'Z',
character >= '0' && character <= '9',
character == '.',
character == '-',
character == '_',
character == '+':
builder.WriteRune(character)
default:
builder.WriteRune('-')
}
}
result := strings.Trim(builder.String(), ".-")
if result == "" {
return "java"
}
return result
}
func (d *download) describe(root string) []string {
directory := d.destination
if root != "" {
directory = filepath.Join(root, d.destination)
}
return []string{
"download " + d.url,
"verify sha256 " + d.checksum,
"extract into " + directory,
}
}
// runDownload downloads, verifies, and extracts the archive under the
// per-user root captured by the plan. A directory that already exists is a
// finished installation.
func runDownload(
ctx context.Context,
host *Host,
d *download,
root string,
out io.Writer,
) error {
if d == nil || d.url == "" || d.checksum == "" {
return errors.New("incomplete java download")
}
if root == "" {
return errors.New("java runtime directory is unknown")
}
if err := os.MkdirAll(root, 0o755); err != nil {
return fmt.Errorf("create java runtime directory: %w", err)
}
target := filepath.Join(root, d.destination)
if installed, err := downloadTargetExists(target); err != nil {
return err
} else if installed {
downloadProgress(out, "Already installed %s", target)
return nil
}
archive, err := os.CreateTemp(root, ".lucy-java-download-*")
if err != nil {
return err
}
archivePath := archive.Name()
defer os.Remove(archivePath)
downloadProgress(
out,
"Downloading %s (%s)",
d.filename,
downloadSize(d.size),
)
if err := fetchDownloadArchive(ctx, host, d, archive); err != nil {
archive.Close()
return err
}
if err := archive.Close(); err != nil {
return err
}
work, err := os.MkdirTemp(root, ".lucy-java-extract-*")
if err != nil {
return err
}
defer os.RemoveAll(work)
downloadProgress(out, "Extracting %s", d.filename)
if err := extractDownloadArchive(archivePath, d.archive, work); err != nil {
return fmt.Errorf("extract %s: %w", d.filename, err)
}
source := work
if entry, ok := downloadSingleDirectory(work); ok {
source = entry
}
if installed, err := downloadTargetExists(target); err != nil {
return err
} else if installed {
downloadProgress(out, "Already installed %s", target)
return nil
}
if err := os.Rename(source, target); err != nil {
return fmt.Errorf("install java into %s: %w", target, err)
}
downloadProgress(out, "Installed %s", target)
return nil
}
// downloadTargetExists reports whether path is an installed runtime. A file
// at path blocks the installation instead of counting as installed.
func downloadTargetExists(path string) (bool, error) {
info, err := os.Stat(path)
switch {
case err == nil && info.IsDir():
return true, nil
case err == nil:
return false, fmt.Errorf("%s exists and is not a directory", path)
case errors.Is(err, os.ErrNotExist):
return false, nil
default:
return false, err
}
}
// fetchDownloadArchive streams the archive into dst and verifies the sha256
// checksum while the bytes arrive. A mismatch leaves dst incomplete.
// The request uses the invocation's configured HTTP client and context.
func fetchDownloadArchive(
ctx context.Context,
host *Host,
d *download,
dst io.Writer,
) error {
response, err := host.request(ctx, d.url)
if err != nil {
return fmt.Errorf("download %s: %w", d.filename, err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf(
"download %s: server responded %s",
d.filename,
response.Status,
)
}
hash := sha256.New()
if _, err := io.Copy(io.MultiWriter(dst, hash), response.Body); err != nil {
return fmt.Errorf("download %s: %w", d.filename, err)
}
actual := hex.EncodeToString(hash.Sum(nil))
if !strings.EqualFold(actual, d.checksum) {
return fmt.Errorf(
"checksum of %s is %s, want %s",
d.filename,
actual,
d.checksum,
)
}
return nil
}
// extractDownloadArchive unpacks the archive into dir. Every file operation
// goes through a root, so an entry cannot reach outside dir, also not through
// a symbolic link that an earlier entry created.
func extractDownloadArchive(archivePath, archiveType, dir string) error {
root, err := os.OpenRoot(dir)
if err != nil {
return err
}
defer root.Close()
switch archiveType {
case "tar.gz":
err = extractDownloadTarGz(archivePath, root)
case "zip":
err = extractDownloadZip(archivePath, root)
default:
err = fmt.Errorf("unsupported archive type %q", archiveType)
}
if err != nil {
return err
}
return verifyDownloadLinks(root)
}
func extractDownloadTarGz(archivePath string, root *os.Root) error {
archive, err := os.Open(archivePath)
if err != nil {
return err
}
defer archive.Close()
compressed, err := gzip.NewReader(archive)
if err != nil {
return err
}
defer compressed.Close()
reader := tar.NewReader(compressed)
for {
header, err := reader.Next()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
if err := extractDownloadTarEntry(reader, header, root); err != nil {
return err
}
}
}
func extractDownloadTarEntry(reader io.Reader, header *tar.Header, root *os.Root) error {
name, err := downloadEntryName(header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
return root.MkdirAll(name, 0o755)
case tar.TypeReg:
return writeDownloadFile(root, name, reader, header.FileInfo().Mode())
case tar.TypeSymlink:
return writeDownloadSymlink(root, name, header.Linkname)
case tar.TypeLink:
target, err := downloadEntryName(header.Linkname)
if err != nil {
return err
}
if err := root.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return err
}
return root.Link(target, name)
default:
return fmt.Errorf(
"archive entry %q has unsupported type %d",
header.Name,
header.Typeflag,
)
}
}
func extractDownloadZip(archivePath string, root *os.Root) error {
archive, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer archive.Close()
for _, entry := range archive.File {
if err := extractDownloadZipEntry(entry, root); err != nil {
return err
}
}
return nil
}
func extractDownloadZipEntry(entry *zip.File, root *os.Root) error {
name, err := downloadEntryName(entry.Name)
if err != nil {
return err
}
mode := entry.Mode()
if mode&os.ModeSymlink != 0 {
reader, err := entry.Open()
if err != nil {
return err
}
target, readErr := io.ReadAll(io.LimitReader(reader, 4096))
closeErr := reader.Close()
if readErr != nil {
return readErr
}
if closeErr != nil {
return closeErr
}
return writeDownloadSymlink(root, name, string(target))
}
if entry.FileInfo().IsDir() {
return root.MkdirAll(name, 0o755)
}
if !mode.IsRegular() {
return fmt.Errorf("archive entry %q has unsupported mode", entry.Name)
}
reader, err := entry.Open()
if err != nil {
return err
}
writeErr := writeDownloadFile(root, name, reader, mode)
return errors.Join(writeErr, reader.Close())
}
func writeDownloadFile(root *os.Root, name string, reader io.Reader, mode os.FileMode) error {
permissions := mode.Perm()
if permissions == 0 {
permissions = 0o644
}
if err := root.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return err
}
file, err := root.OpenFile(
name,
os.O_CREATE|os.O_WRONLY|os.O_TRUNC,
permissions,
)
if err != nil {
return err
}
if _, err := io.Copy(file, reader); err != nil {
return errors.Join(err, file.Close())
}
return file.Close()
}
// writeDownloadSymlink creates a link whose target stays inside the extraction
// directory when read from the link's own path. An absolute target would
// break after the rename. A link can still escape later, through a directory
// link that a later entry creates. verifyDownloadLinks catches that case.
func writeDownloadSymlink(root *os.Root, name, target string) error {
local := filepath.FromSlash(target)
if filepath.IsAbs(local) || filepath.VolumeName(local) != "" {
return fmt.Errorf("archive symlink %q has an absolute target", name)
}
if !downloadContained(filepath.Join(filepath.Dir(name), local)) {
return fmt.Errorf(
"archive symlink %q points outside the extraction directory",
name,
)
}
if err := root.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return err
}
return root.Symlink(local, name)
}
// verifyDownloadLinks rejects a symbolic link that does not resolve inside the
// root. A link target that does not exist is allowed. The check runs after
// extraction, because an entry can create the directory link that another
// link needs in order to escape.
func verifyDownloadLinks(root *os.Root) error {
return fs.WalkDir(root.FS(), ".", func(name string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.Type()&fs.ModeSymlink == 0 {
return nil
}
if _, err := root.Stat(filepath.FromSlash(name)); err != nil &&
!errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf(
"archive symlink %q does not resolve inside the extraction directory: %w",
name,
err,
)
}
return nil
})
}
// downloadEntryName turns an archive name into a path relative to the
// extraction root. It rejects absolute names and names that leave the root.
func downloadEntryName(name string) (string, error) {
converted := filepath.FromSlash(name)
if filepath.IsAbs(converted) || filepath.VolumeName(converted) != "" {
return "", fmt.Errorf("archive entry %q is absolute", name)
}
cleaned := filepath.Clean(converted)
if !downloadContained(cleaned) {
return "", fmt.Errorf(
"archive entry %q escapes the extraction directory",
name,
)
}
return cleaned, nil
}
// downloadContained reports whether a cleaned relative path stays inside its
// root.
func downloadContained(relative string) bool {
return relative != ".." &&
!strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func downloadSingleDirectory(dir string) (string, bool) {
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 1 {
return "", false
}
if !entries[0].IsDir() {
return "", false
}
return filepath.Join(dir, entries[0].Name()), true
}
func downloadProgress(out io.Writer, format string, args ...any) {
if out == nil {
return
}
_, _ = fmt.Fprintf(out, format+"\n", args...)
}
func downloadSize(size int64) string {
const (
kilobyte = 1 << 10
megabyte = 1 << 20
)
switch {
case size >= megabyte:
return fmt.Sprintf("%.0f MB", float64(size)/megabyte)
case size >= kilobyte:
return fmt.Sprintf("%.0f KB", float64(size)/kilobyte)
case size > 0:
return fmt.Sprintf("%d bytes", size)
default:
return "unknown size"
}
}
package javainstall
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/mclucy/lucy/internal/shell"
)
// errChildUnconfirmed reports that a step process ended from a signal other
// than the SIGINT expected after a relayed cancellation. Such a process could
// not have waited for the child it started, so a later restore must not
// assume that child has stopped.
var errChildUnconfirmed = errors.New("process exited from an unexpected signal; a child it started may still be running")
// Execute runs plan, using input for interactive steps and out for output.
// It returns the installed Java home for a managed download.
func (h *Host) Execute(
ctx context.Context,
plan Plan,
policy Policy,
input io.Reader,
out io.Writer,
) (string, error) {
h.logger.Info(
"installing java",
"method", plan.Method,
"package", plan.Package,
"major", plan.Major,
"distribution", plan.Distribution.String(),
)
if plan.download != nil {
if err := runDownload(ctx, h, plan.download, plan.managedRoot, out); err != nil {
return "", err
}
h.logger.Info("java installed", "method", plan.Method, "package", plan.Package)
home := filepath.Join(plan.managedRoot, plan.download.destination)
if info, err := os.Stat(filepath.Join(home, "Contents", "Home")); err == nil && info.IsDir() {
home = filepath.Join(home, "Contents", "Home")
}
return home, nil
}
if plan.Repository != nil {
if err := addRepository(ctx, plan.Repository, policy, h, input, out); err != nil {
return "", fmt.Errorf("add %s: %w", plan.Repository.Name, err)
}
h.logger.Info(
"java repository added",
"repository", plan.Repository.Name,
"url", plan.Repository.URL,
)
}
previous := ""
if plan.KeepDefault {
previous = systemDefaultJava(h)
}
var installErr error
// A step that ended from an unexpected signal may still have a child
// running; do not restore over it.
restoreSafe := true
run := runStep
if plan.KeepDefault && previous != "" {
run = runStepWaitingForStop
}
for _, step := range plan.Steps {
if err := run(ctx, step, policy, h, input, out); err != nil {
installErr = err
restoreSafe = !errors.Is(err, errChildUnconfirmed)
break
}
}
if plan.KeepDefault && previous != "" && len(plan.Steps) > 0 {
if !restoreSafe {
installErr = errors.Join(installErr, fmt.Errorf(
"system default Java %s was not restored because an install process exited from an unexpected signal and its child could not be confirmed stopped",
previous,
))
} else if err := restoreSystemDefault(ctx, previous, policy, h, input, out); err != nil {
restoreErr := fmt.Errorf("restore the system default Java %s: %w", previous, err)
if installErr == nil {
installErr = restoreErr
} else {
installErr = errors.Join(installErr, restoreErr)
}
}
}
if installErr != nil {
return "", installErr
}
h.logger.Info("java installed", "method", plan.Method, "package", plan.Package)
return "", nil
}
// Commands describes what Execute runs, one shell-like line per action, in
// the order Execute runs them.
func (p Plan) Commands() []string {
if p.download != nil {
return p.download.describe(p.managedRoot)
}
var lines []string
if r := p.Repository; r != nil {
if r.Key != nil {
lines = append(
lines,
fmt.Sprintf("download %s and verify fingerprint %s", r.Key.URL, r.Key.Fingerprint),
rootPrefix(p.root)+"write "+r.Key.Path,
)
}
for _, file := range r.Files {
lines = append(lines, rootPrefix(p.root)+"write "+file.Path)
}
for _, step := range r.Steps {
lines = append(lines, step.command(p.root))
}
}
for _, step := range p.Steps {
lines = append(lines, step.command(p.root))
}
return lines
}
// command renders the step as a command line a user can run. root reports
// whether the plan was made as root.
func (s Step) command(root bool) string {
args := s.Args
if s.Elevation == ElevationRoot && !root {
args = append([]string{"sudo"}, args...)
}
quoted := make([]string, len(args))
for i, arg := range args {
quoted[i] = shell.Quote(arg)
}
return strings.Join(quoted, " ")
}
func rootPrefix(root bool) string {
if root {
return ""
}
return "sudo "
}
// runStep runs one step with the ordinary cancellation behavior: a canceled
// context kills the process.
func runStep(
ctx context.Context,
step Step,
policy Policy,
host *Host,
input io.Reader,
out io.Writer,
) error {
return runStepWithCancel(ctx, step, policy, host, input, out, false)
}
// runStepWaitingForStop runs a step whose completion gates a restore. A
// canceled context interrupts the process and waits for it to exit, so the
// restore cannot race an elevated child.
func runStepWaitingForStop(
ctx context.Context,
step Step,
policy Policy,
host *Host,
input io.Reader,
out io.Writer,
) error {
return runStepWithCancel(ctx, step, policy, host, input, out, true)
}
func runStepWithCancel(
ctx context.Context,
step Step,
policy Policy,
host *Host,
input io.Reader,
out io.Writer,
interruptOnCancel bool,
) error {
if len(step.Args) == 0 {
return errors.New("empty install step")
}
args := append([]string(nil), step.Args...)
path, err := host.lookPath(args[0])
if err != nil {
return fmt.Errorf("%s: %w", step.command(host.isRoot()), err)
}
args[0] = path
root := host.isRoot()
if step.Elevation == ElevationRoot && !root {
sudo, err := host.lookPath("sudo")
if err != nil {
return fmt.Errorf("%s: %w", step.command(root), err)
}
prefix := []string{sudo}
if !policy.Interactive {
prefix = append(prefix, "-n")
}
args = append(append(prefix, "--"), args...)
}
host.logger.Info("running java install step", "command", strings.Join(args, " "))
stdin := input
if step.Input != "" {
stdin = strings.NewReader(step.Input)
}
if err := host.system.Run(ctx, Command{
Args: args,
Env: host.environ(),
Stdin: stdin,
Stdout: out,
Stderr: out,
InterruptOnCancel: interruptOnCancel,
}); err != nil {
return fmt.Errorf("%s: %w", step.command(root), err)
}
return nil
}
// writeRootFile writes data at path as root. A non-root process stages the
// bytes in a temporary file and copies them with install(1) through sudo.
func writeRootFile(
ctx context.Context,
path string,
data []byte,
policy Policy,
host *Host,
input io.Reader,
out io.Writer,
) error {
if host.isRoot() {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
staged, err := os.CreateTemp("", "lucy-java-*")
if err != nil {
return err
}
defer os.Remove(staged.Name())
if _, err := staged.Write(data); err != nil {
return errors.Join(err, staged.Close())
}
if err := staged.Close(); err != nil {
return err
}
return runStep(ctx, Step{
Args: []string{"install", "-D", "-m", "0644", staged.Name(), path},
Elevation: ElevationRoot,
}, policy, host, input, out)
}
package javainstall
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/internal/toolchain"
"github.com/mclucy/lucy/log"
)
// probeTimeout bounds a read-only package manager query. A query that exceeds
// it counts as a missing package.
const probeTimeout = 30 * time.Second
// Policy carries the command boundary's choices for a Java installation.
type Policy struct {
// Interactive reports that a terminal can answer password and agreement
// prompts.
Interactive bool
// AllowRepository records the user's consent to add a vendor package
// repository to the system package manager.
AllowRepository bool
}
// Command is one process invocation for a System runner. Args[0] names the
// executable.
type Command struct {
Args []string
Env []string
Dir string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
// InterruptOnCancel asks Run to stop the process gracefully on
// cancellation and wait for it to exit. On Unix it sends an interrupt;
// a wrapper such as sudo relays the interrupt to the command it runs
// and waits for it, so Run returns only after that child has stopped.
// Platforms without process interrupts kill the direct process and
// still wait for it.
InterruptOnCancel bool
}
// System is the process and discovery boundary that one Java host uses. The
// default adapter is NewSystem; tests or alternate environments supply their
// own values without mutable package state. NewHost does not fill missing
// fields, so a supplied System must set every function it will use.
type System struct {
// GOOS and GOARCH name the platform the host plans for.
GOOS string
GOARCH string
// LookPath resolves an executable name on PATH.
LookPath func(name string) (string, error)
// Run executes one command and waits for it. Cancellation follows
// Command.InterruptOnCancel.
Run func(ctx context.Context, command Command) error
// Getenv reads one environment variable.
Getenv func(name string) string
// Environ returns the process environment entries.
Environ func() []string
// UserHomeDir returns the current user's home directory.
UserHomeDir func() (string, error)
// ManagedRoot returns the per-user directory that holds downloaded Java
// runtimes.
ManagedRoot func() (string, error)
// Release returns the fields of /etc/os-release.
Release func() (map[string]string, error)
// IsRoot reports whether Lucy runs as root.
IsRoot func() bool
// EvalSymlinks resolves a symbolic link to its target.
EvalSymlinks func(path string) (string, error)
// UsesMusl reports whether the host runs musl instead of glibc.
UsesMusl func() bool
}
// NewSystem returns the real process and discovery boundary.
func NewSystem() System {
return System{
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
LookPath: exec.LookPath,
Run: runCommand,
Getenv: os.Getenv,
Environ: os.Environ,
UserHomeDir: os.UserHomeDir,
ManagedRoot: toolchain.ManagedJavaRoot,
Release: readSystemRelease,
IsRoot: func() bool { return os.Geteuid() == 0 },
EvalSymlinks: filepath.EvalSymlinks,
UsesMusl: detectMusl,
}
}
func runCommand(ctx context.Context, command Command) error {
if len(command.Args) == 0 {
return errors.New("empty command")
}
process := exec.CommandContext(
ctx,
command.Args[0],
command.Args[1:]...,
)
process.Env = command.Env
process.Dir = command.Dir
process.Stdin = command.Stdin
process.Stdout = command.Stdout
process.Stderr = command.Stderr
if command.InterruptOnCancel {
process.Cancel = func() error {
if process.Process == nil {
return os.ErrProcessDone
}
return interruptCommand(process.Process)
}
// WaitDelay stays zero on purpose: killing the process before it
// exits would orphan the child the interrupt mode exists to wait for.
}
err := process.Run()
if err == nil {
return nil
}
ctxErr := ctx.Err()
if ctxErr != nil && !errors.Is(err, ctxErr) {
err = fmt.Errorf("%w: %w", err, ctxErr)
}
if command.InterruptOnCancel && commandExitUnconfirmed(process.ProcessState, ctxErr != nil) {
return fmt.Errorf("%w: %w", errChildUnconfirmed, err)
}
return err
}
func readSystemRelease() (map[string]string, error) {
data, err := os.ReadFile("/etc/os-release")
if err != nil {
return nil, err
}
fields := map[string]string{}
for line := range strings.Lines(string(data)) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
fields[key] = strings.Trim(strings.TrimSpace(value), `"'`)
}
return fields, nil
}
func detectMusl() bool {
if _, err := os.Stat("/etc/alpine-release"); err == nil {
return true
}
for _, pattern := range []string{
"/lib/ld-musl-*.so.1",
"/usr/lib/ld-musl-*.so.1",
"/lib64/ld-musl-*.so.1",
} {
matches, err := filepath.Glob(pattern)
if err == nil && len(matches) > 0 {
return true
}
}
return false
}
// Host is the per-invocation host boundary for Java provisioning. It finds
// executables, runs read-only probes, names the managed runtime directory,
// reads system release and elevation facts, and reaches the network through
// the invocation's cache client. Planning and execution share one Host, and
// every fact is probed only when a plan needs it. A Host must not be copied
// after first use.
type Host struct {
network *cache.Client
system System
logger *log.Logger
goos string
goarch string
managedOnce sync.Once
managedPath string
managedErr error
releaseOnce sync.Once
release map[string]string
muslOnce sync.Once
musl bool
rootOnce sync.Once
root bool
sudoMu sync.Mutex
sudoKnown bool
sudo bool
}
// NewHost returns the host boundary for one invocation. It probes nothing
// until planning or execution needs a fact. system supplies the process and
// discovery boundary, network carries the invocation's HTTP transport and
// cache, and logger records planning decisions; a nil logger records nothing.
func NewHost(
network *cache.Client,
system System,
logger *log.Logger,
) *Host {
return &Host{
network: network,
system: system,
logger: logger,
goos: system.GOOS,
goarch: system.GOARCH,
}
}
// managedRoot returns the per-user directory that holds downloaded Java
// runtimes.
func (h *Host) managedRoot() (string, error) {
h.managedOnce.Do(func() {
h.managedPath, h.managedErr = h.system.ManagedRoot()
})
return h.managedPath, h.managedErr
}
// systemRelease returns the fields of /etc/os-release. An absent file yields
// an empty map.
func (h *Host) systemRelease() map[string]string {
h.releaseOnce.Do(func() {
release, err := h.system.Release()
if err != nil {
h.logger.Debug("read os-release failed", "reason", err)
release = nil
}
if release == nil {
release = map[string]string{}
}
h.release = release
})
return h.release
}
// usesMusl reports whether the host runs musl instead of glibc.
func (h *Host) usesMusl() bool {
h.muslOnce.Do(func() {
h.musl = h.system.UsesMusl()
})
return h.musl
}
// isRoot reports whether Lucy runs as root.
func (h *Host) isRoot() bool {
h.rootOnce.Do(func() {
h.root = h.system.IsRoot()
})
return h.root
}
// rootAvailable reports whether a step that needs root can run. Without a
// terminal, sudo can only run when it does not ask for a password.
func (h *Host) rootAvailable(ctx context.Context, policy Policy) (bool, error) {
if h.isRoot() {
return true, nil
}
path, err := h.lookPath("sudo")
if err != nil {
return false, nil
}
if policy.Interactive {
return true, nil
}
return h.passwordlessSudo(ctx, path)
}
func (h *Host) passwordlessSudo(ctx context.Context, sudo string) (bool, error) {
h.sudoMu.Lock()
defer h.sudoMu.Unlock()
if err := ctx.Err(); err != nil {
return false, err
}
if h.sudoKnown {
return h.sudo, nil
}
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
err := h.system.Run(probeCtx, Command{
Args: []string{sudo, "-n", "true"},
Env: h.environ(),
})
if err := probeCtx.Err(); err != nil {
return false, err
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false, err
}
h.sudo = err == nil
h.sudoKnown = true
return h.sudo, nil
}
// systemToolAvailable reports whether every named binary is on PATH. System
// package managers run only on Linux.
func (h *Host) systemToolAvailable(names ...string) bool {
if h.goos != "linux" {
return false
}
for _, name := range names {
if _, err := h.lookPath(name); err != nil {
return false
}
}
return true
}
// systemProbe runs a read-only query with a bounded wait. The error carries
// stderr so the caller can report why the query failed.
func (h *Host) systemProbe(ctx context.Context, args ...string) ([]byte, error) {
if len(args) == 0 {
return nil, errors.New("empty system probe")
}
return h.probe(ctx, probeTimeout, nil, args[0], args[1:]...)
}
// probe runs one read-only command with a bounded wait. A nil environment
// uses the supplied system environment.
func (h *Host) probe(
ctx context.Context,
wait time.Duration,
environment []string,
name string,
args ...string,
) ([]byte, error) {
probeCtx, cancel := context.WithTimeout(ctx, wait)
defer cancel()
path, err := h.lookPath(name)
if err != nil {
return nil, err
}
if environment == nil {
environment = h.environ()
}
var stdout, stderr bytes.Buffer
err = h.system.Run(probeCtx, Command{
Args: append([]string{path}, args...),
Env: environment,
Stdout: &stdout,
Stderr: &stderr,
})
if err != nil {
detail := strings.TrimSpace(stderr.String())
line := strings.Join(append([]string{name}, args...), " ")
if detail == "" {
return stdout.Bytes(), fmt.Errorf("%s: %w", line, err)
}
return stdout.Bytes(), fmt.Errorf("%s: %w: %s", line, err, detail)
}
return stdout.Bytes(), nil
}
func (h *Host) lookPath(name string) (string, error) {
return h.system.LookPath(name)
}
func (h *Host) getenv(name string) string {
return h.system.Getenv(name)
}
func (h *Host) environ() []string {
return h.system.Environ()
}
func (h *Host) userHomeDir() (string, error) {
return h.system.UserHomeDir()
}
// fetchMetadata reads a metadata URL through the invocation's cache.
func (h *Host) fetchMetadata(
ctx context.Context,
address string,
ttl time.Duration,
) ([]byte, error) {
return h.network.CachedGetBytes(ctx, address, cache.BytesRequestOptions{
Kind: cache.KindMetadata,
TTL: ttl,
})
}
// request issues one HTTP request through the invocation's client.
func (h *Host) request(
ctx context.Context,
address string,
) (*http.Response, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
if err != nil {
return nil, err
}
return h.network.Do(request)
}
package javainstall
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/mclucy/lucy/java"
)
// miseVendors maps each distribution to the vendor prefix of its mise java
// version.
var miseVendors = map[java.Distribution]string{
java.DistributionTemurin: "temurin",
java.DistributionZulu: "zulu",
java.DistributionCorretto: "corretto",
java.DistributionLiberica: "liberica",
java.DistributionMicrosoft: "microsoft",
java.DistributionOracle: "oracle",
java.DistributionOracleOpenJDK: "openjdk",
java.DistributionGraalVM: "oracle-graalvm",
java.DistributionGraalVMCommunity: "graalvm-community",
java.DistributionSemeru: "semeru",
java.DistributionSapMachine: "sapmachine",
java.DistributionDragonwell: "dragonwell",
java.DistributionKona: "kona",
java.DistributionRedHat: "redhat",
java.DistributionJetBrains: "jetbrains",
}
type mise struct{}
func (mise) method() Method { return MethodMise }
func (mise) system() bool { return false }
func (mise) ownsJava(_ context.Context, host *Host) bool {
dir := miseDataDir(host)
if dir == "" {
return false
}
entries, err := os.ReadDir(filepath.Join(dir, "installs", "java"))
return err == nil && len(entries) > 0
}
func (mise) plans(ctx context.Context, req Request, host *Host) ([]Plan, error) {
if _, err := host.lookPath("mise"); err != nil {
return nil, nil
}
prefix, distribution, ok := miseVendor(req.Distribution)
if !ok {
return nil, nil
}
spec := prefix + "-" + strconv.Itoa(req.Major)
output, err := miseCommand(ctx, host, "ls-remote", "java", spec)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
host.logger.Debug("mise java versions unavailable", "spec", spec, "reason", err)
return nil, nil
}
if strings.TrimSpace(output) == "" {
return nil, nil
}
return []Plan{{
Method: MethodMise,
Package: "java@" + spec,
Distribution: distribution,
Major: req.Major,
Steps: []Step{{
Args: []string{"mise", "install", "java@" + spec},
Elevation: ElevationNone,
}},
}}, nil
}
// miseVendor returns the vendor prefix for dist. The unspecified distribution
// installs Temurin, the mise java default.
func miseVendor(dist java.Distribution) (string, java.Distribution, bool) {
if dist == java.DistributionAny {
return "temurin", java.DistributionTemurin, true
}
prefix, ok := miseVendors[dist]
return prefix, dist, ok
}
func miseDataDir(host *Host) string {
if dir := host.getenv("MISE_DATA_DIR"); dir != "" {
return dir
}
home, err := host.userHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".local", "share", "mise")
}
// miseCommand runs a read-only mise query with a bounded wait.
func miseCommand(
ctx context.Context,
host *Host,
args ...string,
) (string, error) {
output, err := host.probe(ctx, 20*time.Second, nil, "mise", args...)
return string(output), err
}
package javainstall
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/mclucy/lucy/java"
)
// pacman installs Java from the configured Pacman sources.
type pacman struct{}
func (pacman) method() Method { return MethodPacman }
func (pacman) system() bool { return true }
func (pacman) ownsJava(context.Context, *Host) bool { return false }
func (pacman) plans(ctx context.Context, req Request, host *Host) ([]Plan, error) {
if req.Major <= 0 ||
req.Distribution != java.DistributionAny ||
!host.systemToolAvailable("pacman") {
return nil, nil
}
pkg := fmt.Sprintf("jre%d-openjdk-headless", req.Major)
if !pacmanPackageAvailable(ctx, host, pkg) {
info, ok := pacmanPackageInfo(ctx, host, "jre-openjdk-headless")
if !ok || !pacmanProvidesMajor(info, req.Major) {
return nil, nil
}
pkg = "jre-openjdk-headless"
}
return []Plan{
{
Method: MethodPacman,
Package: pkg,
Distribution: java.DistributionAny,
Major: req.Major,
Steps: []Step{
{
Args: []string{
"pacman",
"-S",
"--needed",
"--noconfirm",
pkg,
},
Elevation: ElevationRoot,
},
},
},
}, nil
}
// pacmanPackageInfo returns the repository metadata of pkg, or false when the
// configured sources do not offer it.
func pacmanPackageInfo(
ctx context.Context,
host *Host,
pkg string,
) (string, bool) {
output, err := host.systemProbe(ctx, "pacman", "-Si", pkg)
if err != nil {
host.logger.Debug("pacman package probe failed", "package", pkg, "reason", err)
return "", false
}
return string(output), true
}
func pacmanPackageAvailable(ctx context.Context, host *Host, pkg string) bool {
_, ok := pacmanPackageInfo(ctx, host, pkg)
return ok
}
// pacmanProvidesMajor reports whether the package metadata names major in its
// Provides field, which the unversioned package uses to follow the latest
// release.
func pacmanProvidesMajor(info string, major int) bool {
want := strconv.Itoa(major)
inProvides := false
for line := range strings.Lines(info) {
trimmed := strings.TrimSpace(line)
field, value, hasField := strings.Cut(trimmed, ":")
if hasField {
field = strings.TrimSpace(field)
}
if hasField && !strings.Contains(field, " ") {
inProvides = field == "Provides"
if !inProvides {
continue
}
} else if inProvides {
value = trimmed
} else {
continue
}
for _, token := range strings.Fields(value) {
name, version, ok := strings.Cut(token, "=")
if !ok || version != want {
continue
}
switch name {
case "java-runtime",
"java-runtime-headless",
"jre-openjdk",
"jre-openjdk-headless":
return true
}
}
}
return false
}
//go:build unix
package javainstall
import (
"os"
"syscall"
)
// interruptCommand asks process to stop. The signal comes from a user
// process, so sudo relays it to the command it runs and waits for that
// command to exit.
func interruptCommand(process *os.Process) error {
return process.Signal(os.Interrupt)
}
// commandExitUnconfirmed reports whether the process ended from a signal
// that does not confirm it waited for a child. Only the SIGINT expected
// after a canceled context is treated as the relayed cancellation; any other
// signal may have bypassed that wait.
func commandExitUnconfirmed(state *os.ProcessState, canceled bool) bool {
if state == nil {
return false
}
status, ok := state.Sys().(syscall.WaitStatus)
if !ok || !status.Signaled() {
return false
}
return !canceled || status.Signal() != syscall.SIGINT
}
package javainstall
import (
"context"
"errors"
"fmt"
"io"
"strings"
)
// systemVersionMajor returns the leading release number, because vendor
// repositories name a distribution by its major version.
func systemVersionMajor(version string) string {
major, _, _ := strings.Cut(version, ".")
return major
}
// systemRPMArch maps the Go architecture to the RPM base architecture.
func systemRPMArch(goarch string) string {
switch goarch {
case "amd64":
return "x86_64"
case "386":
return "i686"
case "arm64":
return "aarch64"
case "ppc64le":
return "ppc64le"
case "s390x":
return "s390x"
default:
return ""
}
}
// addRepository writes the verified key and the source files, then runs the
// repository steps. Every write happens as root.
func addRepository(
ctx context.Context,
repo *Repository,
policy Policy,
host *Host,
input io.Reader,
out io.Writer,
) error {
if repo == nil {
return errors.New("no repository to add")
}
if repo.Key != nil {
if err := addRepositoryKey(ctx, repo.Key, policy, host, input, out); err != nil {
return err
}
}
for _, file := range repo.Files {
if err := writeRootFile(
ctx,
file.Path,
[]byte(file.Content),
policy,
host,
input,
out,
); err != nil {
return fmt.Errorf("write %s: %w", file.Path, err)
}
}
for _, step := range repo.Steps {
if err := runStep(ctx, step, policy, host, input, out); err != nil {
return err
}
}
return nil
}
//go:build linux
package javainstall
import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
)
// repositoryKeyMaxBytes is the largest key file Lucy accepts. A vendor key is
// a few kilobytes.
const repositoryKeyMaxBytes = 1 << 20
func addRepositoryKey(
ctx context.Context,
key *RepositoryKey,
policy Policy,
host *Host,
input io.Reader,
out io.Writer,
) error {
entity, err := fetchRepositoryKey(ctx, key, host)
if err != nil {
return err
}
data, err := serializeRepositoryKey(entity, key.Armored)
if err != nil {
return err
}
return writeRootFile(ctx, key.Path, data, policy, host, input, out)
}
// fetchRepositoryKey downloads and verifies the pinned key. A key URL must
// use HTTPS, because the pin protects only the key that arrives.
func fetchRepositoryKey(
ctx context.Context,
key *RepositoryKey,
host *Host,
) (*openpgp.Entity, error) {
address, err := url.Parse(key.URL)
if err != nil {
return nil, fmt.Errorf("parse repository key URL: %w", err)
}
if !strings.EqualFold(address.Scheme, "https") || address.Host == "" {
return nil, fmt.Errorf(
"repository key URL must use HTTPS: %s",
key.URL,
)
}
host.logger.Debug("fetching repository key", "url", key.URL)
probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
response, err := host.request(probeCtx, key.URL)
if err != nil {
return nil, fmt.Errorf("fetch repository key: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf(
"fetch repository key: status %s",
response.Status,
)
}
data, err := io.ReadAll(
io.LimitReader(response.Body, repositoryKeyMaxBytes+1),
)
if err != nil {
return nil, fmt.Errorf("read repository key: %w", err)
}
if int64(len(data)) > repositoryKeyMaxBytes {
return nil, errors.New("repository key exceeds 1 MiB")
}
entity, err := verifyRepositoryKey(data, key.Fingerprint)
if err != nil {
return nil, err
}
return entity, nil
}
// verifyRepositoryKey returns the key whose primary fingerprint matches pin.
// The comparison ignores case and whitespace.
func verifyRepositoryKey(data []byte, pin string) (*openpgp.Entity, error) {
entities, armoredErr := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
if armoredErr != nil {
binaryEntities, binaryErr := openpgp.ReadKeyRing(bytes.NewReader(data))
if binaryErr != nil {
return nil, fmt.Errorf(
"parse OpenPGP key: %w",
errors.Join(armoredErr, binaryErr),
)
}
entities = binaryEntities
}
want := normalizeFingerprint(pin)
if want == "" {
return nil, errors.New("repository key has no pinned fingerprint")
}
for _, entity := range entities {
got := strings.ToUpper(
hex.EncodeToString(entity.PrimaryKey.Fingerprint[:]),
)
if got == want {
return entity, nil
}
}
return nil, fmt.Errorf(
"repository key fingerprint mismatch: want %s",
pin,
)
}
func normalizeFingerprint(fingerprint string) string {
return strings.ToUpper(strings.Join(strings.Fields(fingerprint), ""))
}
func serializeRepositoryKey(
entity *openpgp.Entity,
armored bool,
) ([]byte, error) {
var buffer bytes.Buffer
if !armored {
if err := entity.Serialize(&buffer); err != nil {
return nil, fmt.Errorf("serialize repository key: %w", err)
}
return buffer.Bytes(), nil
}
writer, err := armor.Encode(&buffer, openpgp.PublicKeyType, nil)
if err != nil {
return nil, fmt.Errorf("armor repository key: %w", err)
}
if err := entity.Serialize(writer); err != nil {
return nil, fmt.Errorf("serialize repository key: %w", err)
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("close repository key armor: %w", err)
}
return buffer.Bytes(), nil
}
package javainstall
import (
"context"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/mclucy/lucy/java"
)
// scoop is the Scoop package manager.
type scoop struct{}
// scoopJavaBucketURL is the repository that serves the java bucket.
const scoopJavaBucketURL = "https://github.com/ScoopInstaller/Java"
// scoopJavaPackage describes the manifests of one distribution in the java
// bucket. The formats carry one %d directive for the major. An empty format
// marks a build the bucket does not package.
type scoopJavaPackage struct {
distribution java.Distribution
jreFormat string
jdkFormat string
jreMajors []int
jdkMajors []int
}
// scoopJavaPackages lists the java bucket manifests that exist for each
// distribution.
var scoopJavaPackages = []scoopJavaPackage{
{
distribution: java.DistributionTemurin,
jreFormat: "temurin%d-jre",
jdkFormat: "temurin%d-jdk",
jreMajors: []int{8, 11, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26},
jdkMajors: []int{8, 11, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26},
},
{
distribution: java.DistributionZulu,
jreFormat: "zulu%d-jre",
jdkFormat: "zulu%d-jdk",
jreMajors: []int{8, 11, 12, 13, 14, 15, 16, 17, 21, 22},
jdkMajors: []int{7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 21, 22, 25},
},
{
distribution: java.DistributionCorretto,
jreFormat: "corretto%d-jre",
jdkFormat: "corretto%d-jdk",
jreMajors: []int{8},
jdkMajors: []int{8, 11, 15, 16, 17, 18, 19, 20, 21, 22, 23, 25},
},
{
distribution: java.DistributionLiberica,
jreFormat: "liberica%d-jre",
jdkFormat: "liberica%d-jdk",
jreMajors: []int{8, 11, 13, 14, 15, 16, 17, 21, 25},
jdkMajors: []int{8, 11, 12, 13, 14, 15, 16, 17, 21, 25},
},
{
distribution: java.DistributionMicrosoft,
jdkFormat: "microsoft%d-jdk",
jdkMajors: []int{11, 16, 17, 21},
},
{
distribution: java.DistributionSapMachine,
jreFormat: "sapmachine%d-jre",
jdkFormat: "sapmachine%d-jdk",
jreMajors: []int{11, 12, 13, 14, 15, 16, 17, 18},
jdkMajors: []int{11, 12, 13, 14, 15, 16, 17, 18},
},
{
distribution: java.DistributionSemeru,
jreFormat: "semeru%d-jre",
jdkFormat: "semeru%d-jdk",
jreMajors: []int{8, 11, 16, 17, 18, 19, 20, 21, 22, 23},
jdkMajors: []int{8, 11, 16, 17, 18, 19, 20, 21, 22, 23},
},
{
distribution: java.DistributionGraalVM,
jdkFormat: "graalvm-oracle-%djdk",
jdkMajors: []int{17, 20, 21, 22, 23, 24, 25},
},
{
distribution: java.DistributionGraalVMCommunity,
jdkFormat: "graalvm%d-jdk",
jdkMajors: []int{17, 20, 21, 22, 23, 24, 25},
},
{
distribution: java.DistributionDragonwell,
jdkFormat: "dragonwell%d-jdk",
jdkMajors: []int{8, 11, 17, 21},
},
{
distribution: java.DistributionRedHat,
jreFormat: "openjdk%d-redhat-jre",
jdkFormat: "openjdk%d-redhat",
jreMajors: []int{8},
jdkMajors: []int{8},
},
{
distribution: java.DistributionJetBrains,
jdkFormat: "intellij-jbr%d",
jdkMajors: []int{11, 17, 21, 25},
},
}
// scoopOpenJDKManifestMajors lists the majors with an unbranded openjdk
// manifest. The bucket serves the manifest as openjdk<major>.
var scoopOpenJDKManifestMajors = []int{
9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
}
// scoopCandidate is one manifest that can satisfy a request.
type scoopCandidate struct {
manifest string
distribution java.Distribution
}
func (scoop) method() Method { return MethodScoop }
func (scoop) plans(ctx context.Context, req Request, host *Host) ([]Plan, error) {
if host.goos != "windows" {
return nil, nil
}
if _, err := host.lookPath("scoop.cmd"); err != nil {
return nil, nil
}
hasManifest, err := scoopJavaBucket(ctx, host)
if err != nil {
return nil, err
}
return scoopPlans(req, hasManifest), nil
}
func (scoop) system() bool { return false }
// ownsJava reports whether scoop installed a Java runtime. Every scoop app
// links its active version through a current directory.
func (scoop) ownsJava(_ context.Context, host *Host) bool {
if host.goos != "windows" {
return false
}
root := scoopRoot(host)
if root == "" {
return false
}
matches, err := filepath.Glob(
filepath.Join(root, "apps", "*", "current", "bin", "java.exe"),
)
return err == nil && len(matches) > 0
}
// scoopPlans returns the plan that the java bucket installs req. At most one
// plan results. hasManifest reports whether the installed bucket has a
// manifest. A nil hasManifest means the bucket is absent, so the plan adds it
// and trusts the manifest names.
func scoopPlans(req Request, hasManifest func(string) bool) []Plan {
var chosen scoopCandidate
found := false
for _, candidate := range scoopCandidates(req) {
if hasManifest == nil || hasManifest(candidate.manifest) {
chosen = candidate
found = true
break
}
}
if !found {
return nil
}
// Steps run "scoop" directly. Go's LookPath on Windows resolves "scoop" to
// scoop.cmd via PATHEXT (.com;.exe;.bat;.cmd), and CreateProcess runs a
// .cmd file through cmd.exe. Arguments without cmd.exe metacharacters
// (such as "install" and "java/<name>") need no shell quoting. Other Go
// tools calling Scoop on GitHub (matcha, basecamp-cli, hey-cli, teamcity-cli,
// glazepkg) launch "scoop" the same way.
plan := Plan{
Method: MethodScoop,
Package: "java/" + chosen.manifest,
Distribution: chosen.distribution,
Major: req.Major,
Steps: []Step{{
Args: []string{"scoop", "install", "java/" + chosen.manifest},
Elevation: ElevationNone,
}},
}
if hasManifest == nil {
plan.Repository = &Repository{
Name: "Scoop java bucket",
URL: scoopJavaBucketURL,
Steps: []Step{{
Args: []string{"scoop", "bucket", "add", "java"},
Elevation: ElevationNone,
}},
}
}
return []Plan{plan}
}
// scoopCandidates lists the manifests that can install req, preferred first. A
// request for any build prefers Temurin and falls back to the unbranded
// openjdk manifest.
func scoopCandidates(req Request) []scoopCandidate {
distribution := req.Distribution
if distribution == java.DistributionAny {
distribution = java.DistributionTemurin
}
var candidates []scoopCandidate
for _, pkg := range scoopJavaPackages {
if pkg.distribution == distribution {
candidates = append(
candidates,
scoopPackageCandidates(pkg, req.Major)...,
)
break
}
}
if req.Distribution == java.DistributionAny &&
slices.Contains(scoopOpenJDKManifestMajors, req.Major) {
candidates = append(candidates, scoopCandidate{
manifest: fmt.Sprintf("openjdk%d", req.Major),
distribution: java.DistributionAny,
})
}
return candidates
}
// scoopPackageCandidates returns the JRE manifest before the JDK manifest.
func scoopPackageCandidates(pkg scoopJavaPackage, major int) []scoopCandidate {
var candidates []scoopCandidate
if pkg.jreFormat != "" && slices.Contains(pkg.jreMajors, major) {
candidates = append(candidates, scoopCandidate{
manifest: fmt.Sprintf(pkg.jreFormat, major),
distribution: pkg.distribution,
})
}
if pkg.jdkFormat != "" && slices.Contains(pkg.jdkMajors, major) {
candidates = append(candidates, scoopCandidate{
manifest: fmt.Sprintf(pkg.jdkFormat, major),
distribution: pkg.distribution,
})
}
return candidates
}
// scoopJavaBucket returns a manifest check for the installed java bucket. A
// nil check means the bucket is absent.
func scoopJavaBucket(ctx context.Context, host *Host) (func(string) bool, error) {
output, err := host.probe(
ctx,
probeTimeout,
nil,
"cmd.exe",
"/d",
"/s",
"/c",
"scoop bucket list",
)
if err != nil {
return nil, fmt.Errorf("list scoop buckets: %w", err)
}
if !scoopHasBucket(string(output), "java") {
return nil, nil
}
root := scoopRoot(host)
if root == "" {
return nil, nil
}
dir := filepath.Join(root, "buckets", "java", "bucket")
return func(manifest string) bool {
info, err := os.Stat(filepath.Join(dir, manifest+".json"))
return err == nil && info.Mode().IsRegular()
}, nil
}
// scoopHasBucket reports whether the bucket list output names bucket. The
// first column of each row is the bucket name.
func scoopHasBucket(output, bucket string) bool {
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(line)
if len(fields) > 0 && fields[0] == bucket {
return true
}
}
return false
}
// scoopRoot returns the scoop installation directory.
func scoopRoot(host *Host) string {
if root := host.getenv("SCOOP"); root != "" {
return root
}
home, err := host.userHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, "scoop")
}
package javainstall
import (
"cmp"
"context"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/mclucy/lucy/internal/shell"
"github.com/mclucy/lucy/java"
)
// sdkmanVendors maps the vendor suffix of an SDKMAN candidate identifier to the
// build it names. SDKMAN has no vendor suffix for the remaining distributions.
var sdkmanVendors = map[string]java.Distribution{
"tem": java.DistributionTemurin,
"zulu": java.DistributionZulu,
"amzn": java.DistributionCorretto,
"librca": java.DistributionLiberica,
"ms": java.DistributionMicrosoft,
"oracle": java.DistributionOracle,
"open": java.DistributionOracleOpenJDK,
"graalce": java.DistributionGraalVMCommunity,
"graal": java.DistributionGraalVM,
"sem": java.DistributionSemeru,
"sapmchn": java.DistributionSapMachine,
"kona": java.DistributionKona,
"jbr": java.DistributionJetBrains,
}
type sdkman struct{}
func (sdkman) method() Method { return MethodSDKMAN }
func (sdkman) system() bool { return false }
func (sdkman) ownsJava(_ context.Context, host *Host) bool {
home, ok := sdkmanHome(host)
if !ok {
return false
}
entries, err := os.ReadDir(filepath.Join(home, "candidates", "java"))
if err != nil {
return false
}
for _, entry := range entries {
if entry.Name() != "current" {
return true
}
}
return false
}
func (sdkman) plans(ctx context.Context, req Request, host *Host) ([]Plan, error) {
if host.goos == "windows" {
return nil, nil
}
home, ok := sdkmanHome(host)
if !ok {
return nil, nil
}
initScript := filepath.Join(home, "bin", "sdkman-init.sh")
if _, err := os.Stat(initScript); err != nil {
return nil, nil
}
platform, ok := sdkmanPlatform(host.goos, host.goarch)
if !ok {
return nil, nil
}
versions, err := sdkmanVersions(ctx, host, platform)
if err != nil {
host.logger.Debug(
"sdkman java versions unavailable",
"platform", platform,
"reason", err,
)
return nil, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
id, distribution, ok := sdkmanPick(versions, req)
if !ok {
return nil, nil
}
return []Plan{{
Method: MethodSDKMAN,
Package: id,
Distribution: distribution,
Major: req.Major,
Steps: []Step{{
Args: []string{
"bash",
"-c",
"source " + shell.Quote(initScript) +
" && sdk install java " + shell.Quote(id),
},
Elevation: ElevationNone,
// "n" keeps SDKMAN from making the new candidate the default.
Input: "n\n",
}},
}}, nil
}
// sdkmanHome returns the SDKMAN directory. It is unavailable when neither
// SDKMAN_DIR nor the home directory names one.
func sdkmanHome(host *Host) (string, bool) {
if dir := host.getenv("SDKMAN_DIR"); dir != "" {
return dir, true
}
home, err := host.userHomeDir()
if err != nil {
return "", false
}
return filepath.Join(home, ".sdkman"), true
}
// sdkmanPlatform returns the SDKMAN platform segment for this host.
func sdkmanPlatform(goos, goarch string) (string, bool) {
switch goos + "/" + goarch {
case "darwin/arm64":
return "darwinarm64", true
case "darwin/amd64":
return "darwinx64", true
case "linux/amd64":
return "linuxx64", true
case "linux/arm64":
return "linuxarm64", true
default:
return "", false
}
}
func sdkmanVersions(
ctx context.Context,
host *Host,
platform string,
) ([]string, error) {
data, err := host.fetchMetadata(
ctx,
"https://api.sdkman.io/2/candidates/java/"+platform+"/versions/all",
time.Hour,
)
if err != nil {
return nil, err
}
return strings.Split(string(data), ","), nil
}
// sdkmanPick returns the newest plain candidate for the request. A candidate
// is plain when it is exactly a version and one vendor suffix.
func sdkmanPick(versions []string, req Request) (string, java.Distribution, bool) {
wanted := req.Distribution
if wanted == java.DistributionAny {
wanted = java.DistributionTemurin
}
best := ""
bestVersion := []int(nil)
for _, raw := range versions {
id := strings.TrimSpace(raw)
version, vendor, ok := sdkmanSplit(id)
if !ok {
continue
}
distribution, ok := sdkmanVendors[vendor]
if !ok || distribution != wanted {
continue
}
numbers := sdkmanNumbers(version)
if len(numbers) == 0 || numbers[0] != req.Major {
continue
}
if best == "" || sdkmanCompareVersions(numbers, bestVersion) > 0 {
best = id
bestVersion = numbers
}
}
return best, wanted, best != ""
}
// sdkmanSplit discards variant builds, which carry a second dash or a variant
// tag in the version.
func sdkmanSplit(id string) (string, string, bool) {
if strings.Count(id, "-") != 1 {
return "", "", false
}
version, vendor, ok := strings.Cut(id, "-")
if !ok || version == "" || vendor == "" {
return "", "", false
}
if strings.Contains(version, "fx") || strings.Contains(version, "crac") {
return "", "", false
}
return version, vendor, true
}
func sdkmanNumbers(version string) []int {
fields := strings.FieldsFunc(version, func(r rune) bool {
return r < '0' || r > '9'
})
numbers := make([]int, 0, len(fields))
for _, field := range fields {
number, err := strconv.Atoi(field)
if err != nil {
continue
}
numbers = append(numbers, number)
}
return numbers
}
func sdkmanCompareVersions(a, b []int) int {
for i := 0; i < len(a) && i < len(b); i++ {
if result := cmp.Compare(a[i], b[i]); result != 0 {
return result
}
}
return cmp.Compare(len(a), len(b))
}
package javainstall
import (
"context"
"fmt"
"slices"
"github.com/mclucy/lucy/java"
)
// winget is the Windows Package Manager.
type winget struct{}
// wingetJavaPackage describes the package identifiers of one distribution.
// The formats carry one %d directive for the major. An empty format marks a
// build that winget does not package.
type wingetJavaPackage struct {
distribution java.Distribution
jreFormat string
jdkFormat string
jreMajors []int
jdkMajors []int
}
// wingetJavaPackages lists the package identifiers that exist for each
// distribution.
var wingetJavaPackages = []wingetJavaPackage{
{
distribution: java.DistributionTemurin,
jreFormat: "EclipseAdoptium.Temurin.%d.JRE",
jdkFormat: "EclipseAdoptium.Temurin.%d.JDK",
jreMajors: []int{8, 11, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26},
jdkMajors: []int{8, 11, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26},
},
{
distribution: java.DistributionZulu,
jreFormat: "Azul.Zulu.%d.JRE",
jdkFormat: "Azul.Zulu.%d.JDK",
jreMajors: []int{7, 8, 9, 11, 12, 13, 15, 16, 17, 18, 21, 25},
jdkMajors: []int{
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
23, 24, 25, 26, 27,
},
},
{
distribution: java.DistributionCorretto,
jreFormat: "Amazon.Corretto.%d.JRE",
jdkFormat: "Amazon.Corretto.%d.JDK",
jreMajors: []int{8},
jdkMajors: []int{
8, 11, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
},
},
{
distribution: java.DistributionLiberica,
jreFormat: "BellSoft.LibericaJRE.%d",
jdkFormat: "BellSoft.LibericaJDK.%d",
jreMajors: []int{
8, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
},
jdkMajors: []int{
8, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
26,
},
},
{
distribution: java.DistributionMicrosoft,
jdkFormat: "Microsoft.OpenJDK.%d",
jdkMajors: []int{11, 16, 17, 21, 25},
},
{
distribution: java.DistributionOracle,
jdkFormat: "Oracle.JDK.%d",
jdkMajors: []int{
17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
},
},
{
distribution: java.DistributionSapMachine,
jreFormat: "SAP.SapMachine.%d.JRE",
jdkFormat: "SAP.SapMachine.%d.JDK",
jreMajors: []int{11, 17, 21, 22, 23, 24, 25, 26, 27},
jdkMajors: []int{11, 17, 21, 22, 23, 24, 25, 26, 27},
},
{
distribution: java.DistributionSemeru,
jreFormat: "IBM.Semeru.%d.JRE",
jdkFormat: "IBM.Semeru.%d.JDK",
jreMajors: []int{
8, 11, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
},
jdkMajors: []int{
8, 11, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
},
},
}
func (winget) method() Method { return MethodWinget }
func (winget) plans(ctx context.Context, req Request, host *Host) ([]Plan, error) {
if host.goos != "windows" {
return nil, nil
}
if _, err := host.lookPath("winget.exe"); err != nil {
return nil, nil
}
return wingetPlans(req), nil
}
func (winget) system() bool { return true }
func (winget) ownsJava(context.Context, *Host) bool { return false }
// wingetPlans returns the plan that winget installs req with. At most one
// plan results. A request for any build prefers the Temurin JRE.
func wingetPlans(req Request) []Plan {
identifier, distribution, ok := wingetJavaIdentifier(req)
if !ok {
return nil
}
return []Plan{{
Method: MethodWinget,
Package: identifier,
Distribution: distribution,
Major: req.Major,
Steps: []Step{{
Args: []string{
"winget",
"install",
"--exact",
"--id",
identifier,
"--source",
"winget",
},
Elevation: ElevationSelf,
}},
}}
}
// wingetJavaIdentifier returns the package identifier for req, the build it
// installs, and whether winget packages req. The JRE package is preferred to
// the JDK package.
func wingetJavaIdentifier(req Request) (string, java.Distribution, bool) {
distribution := req.Distribution
if distribution == java.DistributionAny {
distribution = java.DistributionTemurin
}
for _, pkg := range wingetJavaPackages {
if pkg.distribution != distribution {
continue
}
if pkg.jreFormat != "" && slices.Contains(pkg.jreMajors, req.Major) {
return fmt.Sprintf(pkg.jreFormat, req.Major), pkg.distribution, true
}
if pkg.jdkFormat != "" && slices.Contains(pkg.jdkMajors, req.Major) {
return fmt.Sprintf(pkg.jdkFormat, req.Major), pkg.distribution, true
}
}
return "", java.DistributionAny, false
}
package javainstall
import (
"context"
"fmt"
"github.com/mclucy/lucy/java"
)
// zypper installs Java from the configured Zypper sources.
type zypper struct{}
func (zypper) method() Method { return MethodZypper }
func (zypper) system() bool { return true }
func (zypper) ownsJava(context.Context, *Host) bool { return false }
func (zypper) plans(ctx context.Context, req Request, host *Host) ([]Plan, error) {
if req.Major <= 0 ||
req.Distribution != java.DistributionAny ||
!host.systemToolAvailable("zypper") {
return nil, nil
}
pkg := fmt.Sprintf("java-%d-openjdk-headless", req.Major)
if !zypperPackageAvailable(ctx, host, pkg) {
return nil, nil
}
return []Plan{
{
Method: MethodZypper,
Package: pkg,
Distribution: java.DistributionAny,
Major: req.Major,
Steps: []Step{
{
Args: []string{
"zypper",
"--non-interactive",
"install",
"--no-recommends",
pkg,
},
Elevation: ElevationRoot,
},
},
KeepDefault: true,
},
}, nil
}
// zypperPackageAvailable reports whether the configured sources offer pkg.
func zypperPackageAvailable(
ctx context.Context,
host *Host,
pkg string,
) bool {
_, err := host.systemProbe(
ctx,
"zypper",
"--non-interactive",
"--quiet",
"search",
"--match-exact",
"--type",
"package",
pkg,
)
if err != nil {
host.logger.Debug("zypper package probe failed", "package", pkg, "reason", err)
return false
}
return true
}
// Package javaruntime decides which Java runtime a server runs on and how
// that runtime misses the requirement of the locked server. Every miss is a
// warning: Lucy never refuses to run a server because of its Java runtime.
package javaruntime
import (
"fmt"
"slices"
"strconv"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/java"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/version"
)
// dependencyID is the dependency name that package descriptors use for the
// Java runtime.
const dependencyID = "java"
// newestJava bounds the search for a major version that meets every
// constraint.
const newestJava = 99
type constraintKind int
const (
constraintAdvisory constraintKind = iota
constraintPin
constraintPackage
constraintIncompatible
)
// Constraint is one condition on the Java major version.
type Constraint struct {
// Source names who sets the condition, for example "Minecraft 1.20.1" or
// "carpet 1.4.112".
Source string `json:"source"`
// Expression is the condition as its source writes it. For a package that
// declares an incompatibility, it is the range the package rejects.
Expression string `json:"expression"`
kind constraintKind
accepts func(major int) bool
}
// Accepts reports whether Java major meets c.
func (c Constraint) Accepts(major int) bool {
return c.accepts(major)
}
// miss describes how Java major fails c.
func (c Constraint) miss(major int) string {
switch c.kind {
case constraintAdvisory:
return fmt.Sprintf(
"Java %d is older than the Java version that Mojang recommends for %s (%s)",
major,
c.Source,
c.Expression,
)
case constraintPin:
return fmt.Sprintf("Java %d is not Java %s, which %s pins", major, c.Expression, c.Source)
case constraintIncompatible:
return fmt.Sprintf("%s is incompatible with Java %s", c.Source, c.Expression)
default:
return fmt.Sprintf("%s requires Java %s", c.Source, c.Expression)
}
}
// Requirement is the Java runtime that a locked server asks for.
type Requirement struct {
// Advisory is the major version that Mojang recommends.
Advisory int `json:"advisory"`
// Version is the major version that the manifest pins; zero follows the
// constraints.
Version int `json:"version,omitempty"`
// Distribution is the distribution that the manifest pins; empty accepts
// every build.
Distribution java.Distribution `json:"distribution,omitempty"`
Constraints []Constraint `json:"constraints"`
}
// RequirementOf combines the Java inputs that lock records: the Mojang
// advisory, the manifest pin, and the Java requirements of the enabled
// packages.
func RequirementOf(lock *document.Lock, logger *log.Logger) Requirement {
settings := lock.Server.Java
r := Requirement{
Advisory: settings.Advisory,
Version: settings.Version,
Distribution: settings.Distribution,
}
advisory := settings.Advisory
r.Constraints = append(r.Constraints, Constraint{
Source: "Minecraft " + lock.Server.Minecraft.String(),
Expression: fmt.Sprintf(">=%d", advisory),
kind: constraintAdvisory,
accepts: func(major int) bool { return major >= advisory },
})
if pinned := settings.Version; pinned != 0 {
r.Constraints = append(r.Constraints, Constraint{
Source: document.ManifestFilename,
Expression: strconv.Itoa(pinned),
kind: constraintPin,
accepts: func(major int) bool { return major == pinned },
})
}
for i := range lock.Packages {
p := &lock.Packages[i]
if !p.Enabled() {
continue
}
for _, m := range p.Modules {
for _, dep := range m.Dependencies {
if c, ok := packageConstraint(m, dep, advisory, logger); ok &&
!slices.ContainsFunc(r.Constraints, func(known Constraint) bool {
return known.Source == c.Source && known.Expression == c.Expression && known.kind == c.kind
}) {
r.Constraints = append(r.Constraints, c)
}
}
}
}
return r
}
func packageConstraint(
m catalog.DescriptorModule,
dep catalog.DescriptorDependency,
advisory int,
logger *log.Logger,
) (Constraint, bool) {
if dep.ID != dependencyID || dep.Constraint == "" {
return Constraint{}, false
}
kind := constraintPackage
switch dep.Kind {
case catalog.DependencyKindRequired:
case catalog.DependencyKindIncompatible:
kind = constraintIncompatible
default:
return Constraint{}, false
}
expression, dialect := dep.Constraint, m.VersionDialect
match := func(major int) (bool, error) {
return version.Match(expression, version.Version(strconv.Itoa(major)), dialect)
}
if _, err := match(advisory); err != nil {
logger.Debug(
"java requirement ignored",
"module", m.ID,
"constraint", expression,
"reason", err,
)
return Constraint{}, false
}
return Constraint{
Source: m.ID + " " + m.Version.String(),
Expression: expression,
kind: kind,
accepts: func(major int) bool {
matched, _ := match(major)
return matched != (kind == constraintIncompatible)
},
}, true
}
// Unmet returns the constraints that Java major fails.
func (r Requirement) Unmet(major int) []Constraint {
var unmet []Constraint
for _, c := range r.Constraints {
if !c.Accepts(major) {
unmet = append(unmet, c)
}
}
return unmet
}
// Lowest returns the oldest major version that meets every constraint, and
// false when no version does.
func (r Requirement) Lowest() (int, bool) {
for major := document.MinimumJava; major <= newestJava; major++ {
if len(r.Unmet(major)) == 0 {
return major, true
}
}
return 0, false
}
// Target returns the major version to install: the pin, else the oldest
// version that meets every constraint, else the advisory.
func (r Requirement) Target() int {
if r.Version != 0 {
return r.Version
}
if lowest, ok := r.Lowest(); ok {
return lowest
}
return r.Advisory
}
package javaruntime
import (
"cmp"
"context"
"fmt"
"slices"
"sort"
"strconv"
"strings"
"github.com/mclucy/lucy/config"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/internal/toolchain"
"github.com/mclucy/lucy/java"
"github.com/mclucy/lucy/log"
)
// Source names how a Java runtime was selected.
type Source string
const (
// SourceConfigured means configuration selects the runtime.
SourceConfigured Source = "configured"
// SourceInstalled means Lucy picked the runtime among the installed ones.
SourceInstalled Source = "installed"
)
// Runtime is one installed Java runtime.
type Runtime struct {
Home string `json:"home"`
Executable string `json:"executable"`
Version string `json:"version"`
Major int `json:"major"`
Distribution java.Distribution `json:"distribution,omitempty"`
Implementor string `json:"implementor,omitempty"`
}
// Build names the build of r for people, for example "Eclipse Temurin" or
// "Homebrew".
func (r *Runtime) Build() string {
if r.Distribution != java.DistributionAny {
return r.Distribution.Title()
}
if r.Implementor != "" {
return r.Implementor
}
return "unbranded"
}
// Check reports the Java runtime that a server runs on and how the runtime
// misses the requirement of the server.
type Check struct {
// Runtime is nil when no usable runtime exists.
Runtime *Runtime `json:"runtime,omitempty"`
Source Source `json:"source,omitempty"`
// ConfiguredHome names the selected home, even when it is not usable.
ConfiguredHome string `json:"configured_home,omitempty"`
ConfigPath string `json:"config_path,omitempty"`
// Target is the major version that meets the requirement.
Target int `json:"target"`
Distribution java.Distribution `json:"distribution,omitempty"`
// Problems describe each way the selection misses the requirement.
Problems []string `json:"problems,omitempty"`
}
// Satisfied reports whether a runtime exists and meets the requirement.
func (c *Check) Satisfied() bool {
return c != nil && c.Runtime != nil && len(c.Problems) == 0
}
// Executable returns the java executable of the selected runtime, or "" when
// no runtime is usable.
func (c *Check) Executable() string {
if c == nil || c.Runtime == nil {
return ""
}
return c.Runtime.Executable
}
// Select honors a configured Java home without fallback. Otherwise it picks the
// installed runtime that misses the fewest constraints, preferring the oldest
// major version that meets the Mojang advisory.
func Select(ctx context.Context, root string, req Requirement, logger *log.Logger, additionalHomes ...string) (*Check, error) {
check := &Check{
Target: req.Target(),
Distribution: req.Distribution,
}
settings, err := config.New(root, logger)
if err != nil {
return nil, err
}
home, sourcePath, err := settings.JavaHome()
if err != nil {
return nil, err
}
if home != "" {
check.Source = SourceConfigured
check.ConfiguredHome = home
check.ConfigPath = sourcePath
installation, err := toolchain.ProbeJava(ctx, home)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
check.Problems = append(check.Problems, fmt.Sprintf(
"%s selects %s, which is not a usable Java runtime: %v",
sourcePath,
home,
err,
))
return check, nil
}
check.Runtime = runtimeOf(installation)
check.Problems = append(conflicts(req), req.Misses(check.Runtime)...)
logger.Debug(
"java selected",
"home", check.Runtime.Home,
"major", check.Runtime.Major,
"reason", "configuration selects it",
)
return check, nil
}
runtimes, err := Installed(ctx, logger, additionalHomes...)
if err != nil {
return nil, err
}
if len(runtimes) == 0 {
check.Problems = append(check.Problems, "no Java runtime is installed")
return check, nil
}
check.Source = SourceInstalled
check.Runtime = Choose(req, runtimes)
check.Problems = append(conflicts(req), req.Misses(check.Runtime)...)
logger.Debug(
"java selected",
"home", check.Runtime.Home,
"major", check.Runtime.Major,
"problems", len(check.Problems),
"reason", "closest installed runtime to the requirement",
)
return check, nil
}
// Installed includes explicitly supplied homes in ordinary machine discovery.
func Installed(ctx context.Context, logger *log.Logger, additionalHomes ...string) ([]Runtime, error) {
inventory, err := toolchain.Discover(ctx, additionalHomes...)
if err != nil {
return nil, err
}
for _, problem := range inventory.Problems {
logger.Debug("java candidate unusable", "reason", problem)
}
runtimes := make([]Runtime, 0, len(inventory.Java))
for _, installation := range inventory.Java {
runtimes = append(runtimes, *runtimeOf(installation))
}
return runtimes, nil
}
// Choose returns the runtime that misses the fewest parts of req. Among
// equal runtimes it prefers one that meets the Mojang advisory, then the
// oldest major version, then the newest update. runtimes must not be empty.
func Choose(req Requirement, runtimes []Runtime) *Runtime {
ranked := slices.Clone(runtimes)
sort.SliceStable(ranked, func(i, j int) bool {
a, b := &ranked[i], &ranked[j]
return cmp.Or(
cmp.Compare(len(req.Misses(a)), len(req.Misses(b))),
compareBool(a.Major < req.Advisory, b.Major < req.Advisory),
cmp.Compare(a.Major, b.Major),
compareVersions(b.Version, a.Version),
cmp.Compare(a.Home, b.Home),
) < 0
})
return &ranked[0]
}
// conflicts reports a requirement that no Java version meets.
func conflicts(req Requirement) []string {
if _, ok := req.Lowest(); ok {
return nil
}
sources := make([]string, len(req.Constraints))
for i, c := range req.Constraints {
sources[i] = c.Source + " (" + c.Expression + ")"
}
return []string{"no Java version meets every requirement: " + strings.Join(sources, ", ")}
}
// Misses describes each way runtime misses r. An empty result means runtime
// meets r.
func (r Requirement) Misses(runtime *Runtime) []string {
var found []string
for _, c := range r.Unmet(runtime.Major) {
found = append(found, c.miss(runtime.Major))
}
if !r.Distribution.Accepts(runtime.Distribution) {
found = append(found, fmt.Sprintf(
"the Java runtime at %s is a %s build, not %s, which %s pins",
runtime.Home,
runtime.Build(),
r.Distribution.Title(),
document.ManifestFilename,
))
}
return found
}
func runtimeOf(installation toolchain.JavaInstallation) *Runtime {
return &Runtime{
Home: installation.Home,
Executable: installation.Java,
Version: installation.Version,
Major: installation.Major,
Distribution: installation.Distribution,
Implementor: installation.Implementor,
}
}
func compareBool(a, b bool) int {
switch {
case a == b:
return 0
case a:
return 1
default:
return -1
}
}
// compareVersions orders Java version strings such as "21.0.5" and "21.0.13"
// by their numeric parts.
func compareVersions(a, b string) int {
split := func(s string) []string {
return strings.FieldsFunc(s, func(r rune) bool { return r < '0' || r > '9' })
}
left, right := split(a), split(b)
for i := range min(len(left), len(right)) {
l, _ := strconv.Atoi(left[i])
r, _ := strconv.Atoi(right[i])
if c := cmp.Compare(l, r); c != 0 {
return c
}
}
return cmp.Compare(len(left), len(right))
}
// Package log records operational diagnostics independently of user reports.
package log
import (
"fmt"
"io"
"os"
"sync"
charm "charm.land/log/v2"
"github.com/charmbracelet/colorprofile"
)
// Logger owns one diagnostic destination and its optional terminal mirror.
// A nil Logger records nothing.
type Logger struct {
mu sync.RWMutex
file *os.File
path string
diagnostic *charm.Logger
mirror *charm.Logger
capture bool
stages []string
}
// New records all diagnostic levels in path. The level selects which records
// reach mirror; a nil mirror leaves diagnostics in the file only.
func New(path, level string, mirror io.Writer) (*Logger, error) {
threshold, err := charm.ParseLevel(level)
if err != nil {
return nil, fmt.Errorf("log level: %w", err)
}
if path == "" {
path = DefaultFile()
}
file, err := openLogFile(path)
if err != nil {
return nil, err
}
logger := &Logger{
file: file,
path: path,
diagnostic: newLogger(file, charm.DebugLevel),
}
if mirror != nil {
logger.mirror = newLogger(mirror, threshold)
}
return logger, nil
}
func newLogger(destination io.Writer, level charm.Level) *charm.Logger {
logger := charm.NewWithOptions(destination, charm.Options{
ReportTimestamp: true,
TimeFormat: "2006-01-02 15:04:05",
Level: level,
Formatter: charm.TextFormatter,
})
logger.SetColorProfile(colorprofile.NoTTY)
return logger
}
func (l *Logger) Info(content any, fields ...any) {
l.write(charm.InfoLevel, content, fields...)
}
func (l *Logger) Debug(content any, fields ...any) {
l.write(charm.DebugLevel, content, fields...)
}
func (l *Logger) Warn(err error) {
if err != nil {
l.write(charm.WarnLevel, err)
}
}
func (l *Logger) Error(err error) {
if err != nil {
l.write(charm.ErrorLevel, err)
}
}
func (l *Logger) write(level charm.Level, content any, fields ...any) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.capture && len(l.stages) < 128 {
if message, ok := content.(string); ok && diagnosticStage(message) {
l.stages = append(l.stages, message)
}
}
l.diagnostic.Log(level, content, fields...)
if l.mirror != nil {
l.mirror.Log(level, content, fields...)
}
}
// CaptureStages enables the bounded, allowlisted diagnostic record for this
// invocation. Arbitrary messages and fields remain in the local log only.
func (l *Logger) CaptureStages() {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
l.capture = true
}
// Stages returns the captured messages without exposing log fields.
func (l *Logger) Stages() []string {
if l == nil {
return nil
}
l.mu.RLock()
defer l.mu.RUnlock()
return append([]string(nil), l.stages...)
}
func diagnosticStage(message string) bool {
switch message {
case "action invoked", "action completed",
"resolving installation", "installation decision",
"installation already current", "installation not current",
"installation current", "staging installation", "packages staged",
"installation transaction journaled", "installation committed",
"lock resolution reused current lock", "core pin not reusable",
"installing java", "java installed", "java repository added",
"upgrade: candidate combination failed":
return true
default:
return false
}
}
func (l *Logger) FilePath() string {
if l == nil {
return ""
}
return l.path
}
func (l *Logger) Close() error {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
if l.file == nil {
return nil
}
l.diagnostic.SetOutput(io.Discard)
if l.mirror != nil {
l.mirror.SetOutput(io.Discard)
}
err := l.file.Close()
l.file = nil
if err != nil {
return fmt.Errorf("close log file %q: %w", l.path, err)
}
return nil
}
package log
import (
"fmt"
"os"
"path/filepath"
"runtime"
)
func DefaultFile() string {
var directory string
switch runtime.GOOS {
case "windows":
directory = filepath.Join(os.Getenv("APPDATA"), "lucy", "logs")
case "darwin":
directory = filepath.Join(os.Getenv("HOME"), "Library", "Logs", "lucy")
case "linux":
directory = filepath.Join(os.Getenv("HOME"), ".local", "share", "lucy", "logs")
default:
directory = "logs"
}
return filepath.Join(directory, "lucy.log")
}
func openLogFile(path string) (*os.File, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create log directory: %w", err)
}
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("open log file %q: %w", path, err)
}
return file, nil
}
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"github.com/mclucy/lucy/cmd"
"github.com/mclucy/lucy/internal/cli"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
directory, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "determine invocation directory: %v\n", err)
stop()
os.Exit(1)
}
code := run(ctx, os.Args[1:], directory, os.Stdin, os.Stdout, os.Stderr)
stop()
os.Exit(code)
}
func run(ctx context.Context, args []string, directory string, stdin io.Reader, stdout, stderr io.Writer) (exitCode int) {
runner := cmd.NewRunner(args, cmd.RunnerOptions{
Directory: directory,
Stdin: stdin,
Stdout: stdout,
Stderr: stderr,
})
executeErr := runner.Execute(ctx)
closeErr := runner.Close()
if executeErr == nil && closeErr == nil {
executeErr = runner.FlushOutput()
}
var exitErr *cli.ExitError
if errors.As(executeErr, &exitErr) {
if closeErr != nil {
if runner.JSONSelected() {
runner.WriteError(errors.Join(exitErr.Err, closeErr))
} else {
runner.WriteError(closeErr)
}
return 1
}
if exitErr.Silent {
if flushErr := runner.FlushOutput(); flushErr != nil {
runner.WriteError(flushErr)
return 2
}
return exitErr.Code
}
if runner.JSONSelected() {
runner.WriteError(exitErr.Err)
}
return exitErr.Code
}
switch {
case runner.JSONSelected():
// One document on stderr. A close failure joins the action failure
// instead of silently disappearing or producing a second document.
if err := errors.Join(executeErr, closeErr); err != nil {
runner.WriteError(err)
}
case closeErr != nil:
runner.WriteError(closeErr)
}
switch {
case executeErr == nil && closeErr == nil:
return 0
case errors.Is(executeErr, context.Canceled):
return 130
default:
return 1
}
}
package operation
import (
"context"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"maps"
"os"
"path/filepath"
"slices"
"strings"
documentpkg "github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/artifactstore"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/config"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/internal/workspacewrite"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/resolve"
serverpkg "github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/workspace"
"github.com/mclucy/lucy/workspace/discovery"
"github.com/mclucy/lucy/workspace/identify"
)
// InitOptions describes adoption of the server already present in a
// directory.
type InitOptions struct {
Name string
MCDRVersion string
Force bool
NoComments bool
// EntryPoint selects the runtime observation uses for this
// initialization only; it is never persisted.
EntryPoint string
// PromptRuntime asks which entry point to use when observation finds
// several equally valid runtimes. Nil keeps the strict failure.
PromptRuntime func(discovery.Result) (string, error)
// Trace receives progress for the lock resolution initialization runs.
Trace *install.Trace
// Materialize supplies artifact bytes for adoption planning. Nil uses the
// configured cache and network.
Materialize func(*catalog.Artifact) (artifact.MaterializedFile, error)
}
// InitResult is the structured outcome of one initialization. The document
// is the written manifest; warnings and discovery carry the observations
// that the command boundary presents.
type InitResult struct {
Document *documentpkg.Manifest
Lock *documentpkg.Lock
PublishedLock bool
PublishedConfig bool
Warnings []error
Discovery discovery.Result
}
func commandDocuments(root string, logger *log.Logger) (document *documentpkg.Manifest, locked *documentpkg.Lock, resultErr error) {
writer, err := workspacewrite.Lock(root, logger)
if err != nil {
return nil, nil, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
if err := filetrack.RecoverLocked(root, logger); err != nil {
return nil, nil, err
}
d, err := documentpkg.ManifestFile(root).Read()
if err != nil {
return nil, nil, err
}
old, err := documentpkg.LockFile(root).Read()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, nil, err
}
return d, old, nil
}
// pinWorkspaceDocuments fills the state an operation selected from when the
// caller did not already pin it. The installation re-locks the workspace, so a
// recovery or concurrent edit in between fails instead of being overwritten.
func pinWorkspaceDocuments(opts *install.Options, d *documentpkg.Manifest, old *documentpkg.Lock) error {
if d != nil && opts.ExpectedManifest == "" {
fingerprint, err := d.Fingerprint()
if err != nil {
return err
}
opts.ExpectedManifest = fingerprint
}
if old == nil || opts.ExpectedLock != "" {
return nil
}
fingerprint, err := old.Fingerprint()
if err != nil {
return err
}
opts.ExpectedLock = fingerprint
return nil
}
func selectedEcosystem(d *documentpkg.Manifest, requested catalog.Ecosystem) catalog.Ecosystem {
if requested != catalog.EcosystemUnspecified {
return requested
}
switch d.Server.Core.Product {
case serverpkg.ProductFabric:
return catalog.EcosystemFabric
case serverpkg.ProductForge:
return catalog.EcosystemForge
case serverpkg.ProductNeoForge:
return catalog.EcosystemNeoForge
case serverpkg.ProductPaper, serverpkg.ProductPurpur:
return catalog.EcosystemPaper
}
if d.Server.Core.Repository != "" {
return catalog.EcosystemPaper
}
if d.MCDR != nil {
return catalog.EcosystemMCDR
}
return catalog.EcosystemUnspecified
}
// servedEcosystemSet returns the package ecosystems that the declared server
// can load. A probed runtime also reports bridged ecosystems, such as Fabric
// through Sinytra Connector. Only ecosystems the declared product loads enter
// the set. Without a probe, the declared product decides.
func servedEcosystemSet(d *documentpkg.Manifest, observed *workspace.Workspace) map[catalog.Ecosystem]struct{} {
target := resolve.Target{Product: d.Server.Core.Product}
served := map[catalog.Ecosystem]struct{}{}
if observed != nil {
for _, offer := range observed.ServedEcosystems() {
if resolve.SupportsPackages(target, offer.Ecosystem) {
served[offer.Ecosystem] = struct{}{}
}
}
}
if len(served) == 0 {
if ecosystem := selectedEcosystem(d, catalog.EcosystemUnspecified); ecosystem != catalog.EcosystemUnspecified {
served[ecosystem] = struct{}{}
}
}
return served
}
// servedEcosystemForFile reports the served loader whose descriptor the file
// carries. A jar without a top-level descriptor can declare its loader inside
// a JarJar container, while the upstream project label may name a loader the
// server does not offer.
func servedEcosystemForFile(
ctx context.Context,
path string,
served map[catalog.Ecosystem]struct{},
) (catalog.Ecosystem, bool) {
for _, ecosystem := range slices.Sorted(maps.Keys(served)) {
if ecosystem == catalog.EcosystemMCDR {
continue
}
modules, err := artifact.ReadModules(ctx, path, ecosystem)
if err != nil {
continue
}
for _, module := range modules {
if loaderSpecificDescriptor(module) {
return ecosystem, true
}
}
}
return catalog.EcosystemUnspecified, false
}
// loaderSpecificDescriptor reports whether a module's descriptor names the
// loader that read it. Forge and NeoForge share META-INF/mods.toml, so only a
// loader dependency declared inside it identifies the loader.
func loaderSpecificDescriptor(module catalog.DescriptorModule) bool {
if module.DescriptorPath != "META-INF/mods.toml" {
return true
}
loader := module.Ecosystem.String()
return slices.ContainsFunc(module.Dependencies, func(dep catalog.DescriptorDependency) bool {
return dep.ID == loader
})
}
func commandReference(d *documentpkg.Manifest, old *documentpkg.Lock, raw string, ecosystem catalog.Ecosystem) (string, version.Version, error) {
request, err := input.Parse(raw)
if err != nil {
return "", "", err
}
provider, name := request.Provider, string(request.Name)
if provider == catalog.ProviderUnspecified {
if old != nil {
ref, err := old.LookupReference(name)
if err != nil {
return "", "", err
}
if ref != "" {
provider, name, err = documentpkg.ParseReference(ref)
if err != nil {
return "", "", err
}
}
}
if provider == catalog.ProviderUnspecified {
provider = catalog.ProviderModrinth
if ecosystem == catalog.EcosystemMCDR {
provider = catalog.ProviderMCDR
}
}
}
ref := provider.String() + ":" + name
if _, _, err := documentpkg.ParseReference(ref); err != nil {
return "", "", err
}
selector := request.Version
if selector == "" {
selector = version.Any
}
return ref, selector, nil
}
func (s *Service) Add(ctx context.Context, root string, args []string, ecosystem catalog.Ecosystem, opts install.Options) (*install.Result, error) {
d, old, err := commandDocuments(root, s.logger)
if err != nil {
return nil, err
}
if err := pinWorkspaceDocuments(&opts, d, old); err != nil {
return nil, err
}
opts.RequirementChange = true
ecosystem = selectedEcosystem(d, ecosystem)
if ecosystem == catalog.EcosystemUnspecified {
return nil, fmt.Errorf("specify a package ecosystem supported by the declared server")
}
for _, raw := range args {
ref, selector, err := commandReference(d, old, raw, ecosystem)
if err != nil {
return nil, err
}
r := documentpkg.Requirement{Selector: selector, Enabled: new(true)}
if ecosystem == catalog.EcosystemMCDR {
if d.MCDR == nil {
return nil, fmt.Errorf("manifest has no MCDR runtime")
}
d.MCDR.Packages[ref] = r
} else {
if d.Server.Packages[ecosystem] == nil {
d.Server.Packages[ecosystem] = map[string]documentpkg.Requirement{}
}
d.Server.Packages[ecosystem][ref] = r
}
}
opts.PublishManifest = true
return s.install.Sync(ctx, root, d, old, opts)
}
func (s *Service) Remove(ctx context.Context, root string, args []string, ecosystem catalog.Ecosystem, opts install.Options) (*install.Result, error) {
d, old, err := commandDocuments(root, s.logger)
if err != nil {
return nil, err
}
if err := pinWorkspaceDocuments(&opts, d, old); err != nil {
return nil, err
}
opts.RequirementChange = true
ecosystem = selectedEcosystem(d, ecosystem)
for _, raw := range args {
ref, _, err := commandReference(d, old, raw, ecosystem)
if err != nil {
return nil, err
}
packages := d.Server.Packages[ecosystem]
if ecosystem == catalog.EcosystemMCDR {
if d.MCDR == nil {
return nil, fmt.Errorf("manifest has no MCDR runtime")
}
packages = d.MCDR.Packages
}
if _, ok := packages[ref]; !ok {
return nil, fmt.Errorf("direct requirement %s not found in %s", ref, ecosystem)
}
delete(packages, ref)
}
opts.PublishManifest = true
return s.install.Sync(ctx, root, d, old, opts)
}
func (s *Service) Initialize(ctx context.Context, root string, opts InitOptions) (result *InitResult, resultErr error) {
root, err := filepath.Abs(root)
if err != nil {
return nil, err
}
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, err
}
lease, err := artifactstore.AcquireOperation()
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, lease.Close()) }()
writer, err := workspacewrite.Lock(root, s.logger)
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
if err := filetrack.RecoverLocked(root, s.logger); err != nil {
return nil, err
}
if _, err := os.Stat(filepath.Join(root, documentpkg.ManifestFilename)); err == nil && !opts.Force {
return nil, fmt.Errorf("lucy.yaml exists; use --force to replace declared intent")
}
name := strings.TrimSpace(opts.Name)
if name == "" && opts.Force {
// Replacing a declaration keeps the name the user already chose; an
// unreadable or nameless manifest falls back to the directory name.
if existing, err := documentpkg.ManifestFile(root).Read(); err == nil {
name = existing.Name
}
}
if name == "" {
name = filepath.Base(root)
}
d := &documentpkg.Manifest{
SchemaVersion: documentpkg.ManifestSchemaVersion,
Name: name,
Server: documentpkg.ManifestServer{
Packages: map[catalog.Ecosystem]map[string]documentpkg.Requirement{},
},
}
observed := workspace.Observe(root, discovery.Request{EntryPoint: opts.EntryPoint})
if opts.EntryPoint == "" && observed.Runtime() == nil && opts.PromptRuntime != nil {
var selectionErr *discovery.SelectionError
if errors.As(observed.SelectionError(), &selectionErr) &&
selectionErr.State == discovery.SelectionAmbiguous {
entryPoint, promptErr := opts.PromptRuntime(observed.Discovery)
if promptErr != nil {
return initResult(observed), promptErr
}
if entryPoint != "" {
if !filepath.IsAbs(entryPoint) {
entryPoint = filepath.Join(root, entryPoint)
}
if err := observed.Select(discovery.Request{EntryPoint: entryPoint}); err != nil {
return initResult(observed), err
}
}
}
}
if selection := observed.Runtime(); selection != nil {
identity := selection.Installation.Identity
d.Server.Minecraft = selection.MinecraftVersion()
if detected := selection.Product(); detected != serverpkg.ProductUnknown {
d.Server.Core.Product = detected
} else {
d.Server.Core.Product = serverpkg.Product(identity.Primary.Name.String())
}
if identity.Primary.Version != "" &&
!identity.Primary.Version.IsInvalid() &&
!identity.Primary.Version.CanInfer() {
d.Server.Core.ProductVersion = identity.Primary.Version
}
if paperLineageProduct(d.Server.Core.Product) &&
!concreteVersion(d.Server.Core.ProductVersion) {
build, found, err := s.identifyInstalledCoreBuild(
ctx,
root,
selection,
d.Server.Core.Product,
)
if err != nil {
return initResult(observed), err
}
if !found {
return initResult(observed), fmt.Errorf(
"cannot identify the installed %s build; restore the "+
"published %s-<minecraft>-<build>.jar name or pin "+
"the core version in lucy.yaml",
d.Server.Core.Product,
d.Server.Core.Product,
)
}
d.Server.Core.ProductVersion = build
}
if observed.ServerDir == "" {
d.Server.Directory = "."
} else {
relative, err := filepath.Rel(root, observed.ServerDir)
if err != nil {
return initResult(observed), err
}
d.Server.Directory = filepath.ToSlash(relative)
}
s.logger.Debug(
"detected server runtime",
"minecraft", d.Server.Minecraft,
"product", d.Server.Core.Product,
"core-version", d.Server.Core.ProductVersion,
"directory", d.Server.Directory,
)
} else {
if selectionErr := observed.SelectionError(); selectionErr != nil {
var typed *discovery.SelectionError
noServer := errors.As(selectionErr, &typed) &&
(typed.State == discovery.SelectionNone ||
(typed.State == discovery.SelectionInvalid && typed.Path == ""))
if !noServer {
return initResult(observed), selectionErr
}
}
return initResult(observed), fmt.Errorf(
"no server detected; use `lucy create` to generate a new server",
)
}
var identificationErrors []error
observed, identificationErrors = identify.Packages(ctx, observed, s.source.ResolveArtifactByHash)
if err := ctx.Err(); err != nil {
return initResult(observed), err
}
for _, err := range identificationErrors {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return initResult(observed), err
}
}
if d.Server.Minecraft == "" || d.Server.Minecraft.IsInvalid() {
return initResult(observed), fmt.Errorf(
"the detected server has no concrete Minecraft version",
)
}
if d.Server.Core.Product == serverpkg.ProductUnknown {
d.Server.Core.Product = serverpkg.ProductMinecraft
s.logger.Debug("core product defaulted", "product", d.Server.Core.Product)
}
mcdrVersion := opts.MCDRVersion
if mcdrVersion == "" && observed != nil && observed.Wrappers.MCDR != nil {
v := observed.Wrappers.MCDR.Version
if v.IsInvalid() || v.CanInfer() {
return initResult(observed), fmt.Errorf("MCDR detected without an exact app version; specify --mcdr-version")
}
mcdrVersion = string(v)
s.logger.Debug("MCDR version from workspace detection", "version", mcdrVersion)
}
if mcdrVersion != "" {
d.MCDR = &documentpkg.ManifestMCDR{Directory: ".", Version: version.Version(mcdrVersion), Packages: map[string]documentpkg.Requirement{}}
}
if err := d.Normalize(); err != nil {
return initResult(observed), err
}
modrinthMapper, hasModrinthMapper, err := s.source.GetArtifactMapper(catalog.ProviderModrinth)
if err != nil {
return initResult(observed), err
}
served := servedEcosystemSet(d, observed)
var packages []workspace.DiscoveredPackage
if observed != nil {
packages = observed.Packages
}
recognized := 0
ownedPackages := []string{}
for _, p := range packages {
if !artifact.SupportsPath(p.Path) {
continue
}
ecosystem := p.ID.Ecosystem
if ecosystem != catalog.EcosystemMCDR {
if _, ok := served[ecosystem]; !ok {
var adopted bool
ecosystem, adopted = servedEcosystemForFile(ctx, p.Path, served)
if !adopted {
continue
}
}
}
modules, err := artifact.ReadModules(ctx, p.Path, ecosystem)
if err != nil || len(modules) == 0 {
continue
}
if ecosystem == catalog.EcosystemMCDR && d.MCDR != nil {
pluginDirectory := filepath.Join(root, d.MCDR.Directory, "plugins")
if filepath.Clean(filepath.Dir(p.Path)) != filepath.Clean(pluginDirectory) {
observed.Warnings = append(observed.Warnings, fmt.Errorf(
"plugin %s left unmanaged: MCDReforged loads it outside the default plugins directory",
filepath.Base(p.Path),
))
continue
}
candidates, err := s.source.Candidates(ctx, catalog.RegistryMCDR, modules[0].ID, string(d.Server.Minecraft), ecosystem)
if err != nil {
continue
}
for _, candidate := range candidates {
if candidate.Hashes["sha256"] == "" {
continue
}
if _, err := install.DigestFile(p.Path, candidate.Hashes); err == nil {
d.MCDR.Packages["mcdr:"+candidate.ProjectID] = documentpkg.Requirement{Selector: candidate.Version}
recognized++
ownedPackages = append(ownedPackages, p.Path)
break
}
}
continue
}
if ecosystem != catalog.EcosystemFabric && ecosystem != catalog.EcosystemForge && ecosystem != catalog.EcosystemNeoForge && ecosystem != catalog.EcosystemBukkit && ecosystem != catalog.EcosystemPaper {
continue
}
if !hasModrinthMapper {
continue
}
ref, _, matched, err := modrinthMapper.PackageByHash(ctx, artifact.File{Path: p.Path})
if err != nil || !matched || ref.Name == "" {
continue
}
// A manifest entry that resolution cannot select blocks every later
// command, so an installed package whose catalogue metadata offers no
// matching release for this server stays unmanaged.
candidates, candidateErr := s.source.Candidates(
ctx, catalog.RegistryModrinth, ref.Name.String(),
string(d.Server.Minecraft), ecosystem,
)
usable := candidateErr == nil && slices.ContainsFunc(
candidates,
func(candidate upstream.Candidate) bool {
return candidate.Version == ref.Version && candidate.PrimaryFile
},
)
if !usable {
if candidateErr == nil {
candidateErr = fmt.Errorf("no release provides version %s", ref.Version)
}
if observed != nil {
observed.Warnings = append(observed.Warnings, fmt.Errorf("package %s left unmanaged: %w", ref.Name, candidateErr))
}
continue
}
if d.Server.Packages[ecosystem] == nil {
d.Server.Packages[ecosystem] = map[string]documentpkg.Requirement{}
}
d.Server.Packages[ecosystem]["modrinth:"+ref.Name.String()] = documentpkg.Requirement{Selector: ref.Version}
recognized++
ownedPackages = append(ownedPackages, p.Path)
}
s.logger.Debug(
"recognized existing packages",
"count", recognized,
"present", len(packages),
)
old, err := documentpkg.LockFile(root).Read()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return initResult(observed), err
}
if errors.Is(err, os.ErrNotExist) {
old = nil
}
// Adoption records the set the server already runs, so module
// requirements that the installed jars report but the catalogue metadata
// cannot satisfy become warnings instead of a refusal.
resolved, resolution, err := s.install.Resolve(
ctx,
root,
d,
old,
install.Options{AcceptBroken: true, Trace: opts.Trace, Materialize: opts.Materialize},
)
if observed != nil && resolution != nil {
observed.Warnings = append(observed.Warnings, resolution.Warnings...)
}
if err != nil {
return initResult(observed), err
}
configurationPath := filepath.Join(root, filepath.FromSlash(config.WorkspaceFilename))
if err := workspacewrite.SafePath(root, config.WorkspaceFilename); err != nil {
return initResult(observed), err
}
_, configErr := os.Lstat(configurationPath)
publishedConfig := errors.Is(configErr, os.ErrNotExist)
if configErr != nil && !publishedConfig {
return initResult(observed), configErr
}
manifestFile := documentpkg.ManifestFile(root)
if opts.Force {
manifestFile = manifestFile.WithoutMerge()
}
if opts.NoComments {
manifestFile = manifestFile.WithEncoder((*documentpkg.Manifest).PlainBytes)
}
if err := ctx.Err(); err != nil {
return initResult(observed), err
}
if err := manifestFile.Write(d); err != nil {
return initResult(observed), err
}
// Once lucy.yaml exists, later publication failures still report the
// written document. Registration precedes lock publication so a successful
// initialization is visible to the artifact store's sweep.
result = &InitResult{Document: d}
if observed != nil {
result.Warnings = observed.Warnings
result.Discovery = observed.Discovery
}
duplicates, err := s.registerWorkspace(root)
if err != nil {
return result, fmt.Errorf("register server: %w", err)
}
for _, duplicate := range duplicates {
result.Warnings = append(result.Warnings, fmt.Errorf(
"server name %q is also registered at %s; --server cannot select an ambiguous name",
duplicate.Name,
duplicate.Path,
))
}
if resolved != old {
if err := documentpkg.LockFile(root).Write(resolved); err != nil {
return result, err
}
result.PublishedLock = true
}
result.Lock = resolved
if err := install.RecordAdoptedPackages(root, resolved, ownedPackages); err != nil {
return result, err
}
if publishedConfig {
created, err := config.CreateWorkspace(root, opts.NoComments)
if err != nil {
return result, err
}
publishedConfig = created
}
result.PublishedConfig = publishedConfig
return result, nil
}
// initResult carries the observations of a failed initialization so the
// command boundary can present them alongside the failure.
func initResult(observed *workspace.Workspace) *InitResult {
if observed == nil {
return nil
}
return &InitResult{
Warnings: observed.Warnings,
Discovery: observed.Discovery,
}
}
func concreteVersion(v version.Version) bool {
return v != "" && !v.IsInvalid() && !v.CanInfer()
}
func paperLineageProduct(product serverpkg.Product) bool {
switch product {
case serverpkg.ProductPaper, serverpkg.ProductPurpur, serverpkg.ProductFolia:
return true
default:
return false
}
}
func (s *Service) identifyInstalledCoreBuild(
ctx context.Context,
root string,
selection *discovery.Selection,
product serverpkg.Product,
) (version.Version, bool, error) {
if selection == nil || selection.EntryPoint == nil || selection.EntryPoint.Path == "" {
return "", false, nil
}
path := selection.EntryPoint.Path
if !filepath.IsAbs(path) {
path = filepath.Join(root, path)
}
file, err := os.Open(path)
if err != nil {
return "", false, fmt.Errorf("read %s jar: %w", product, err)
}
sha := sha256.New()
md := md5.New()
_, copyErr := io.Copy(io.MultiWriter(sha, md), file)
closeErr := file.Close()
if copyErr != nil {
return "", false, copyErr
}
if closeErr != nil {
return "", false, closeErr
}
var build version.Version
var found bool
switch product {
case serverpkg.ProductPaper:
build, found, err = s.source.IdentifyFillBuild(
ctx,
"paper",
selection.MinecraftVersion(),
hex.EncodeToString(sha.Sum(nil)),
)
case serverpkg.ProductFolia:
build, found, err = s.source.IdentifyFillBuild(
ctx,
"folia",
selection.MinecraftVersion(),
hex.EncodeToString(sha.Sum(nil)),
)
case serverpkg.ProductPurpur:
build, found, err = s.source.IdentifyPurpurBuild(
ctx,
selection.MinecraftVersion(),
hex.EncodeToString(md.Sum(nil)),
)
}
if err != nil {
return "", false, fmt.Errorf("identify installed %s build: %w", product, err)
}
return build, found, nil
}
package operation
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/artifactstore"
"github.com/mclucy/lucy/catalog"
documentpkg "github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/internal/workspacewrite"
serverpkg "github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/version"
)
// CreateKind identifies the machine-readable document this operation emits.
const CreateKind = "create"
// CreateOptions describes a new workspace declaration and the installation
// that may follow it. Directory names the workspace root; the server occupies
// that directory directly.
type CreateOptions struct {
Directory string
Name string
Minecraft string
Product string
ProductVersion string
MCDRVersion string
Force bool
Install bool
InstallOptions install.Options
Trace *install.Trace
// Materialize supplies artifact bytes for planning and the optional
// installation. Nil uses the configured cache and network.
Materialize func(*catalog.Artifact) (artifact.MaterializedFile, error)
}
// CreateTarget is the result of observing a directory before creation. It
// writes nothing.
type CreateTarget struct {
Path string
Exists bool
NonEmpty bool
ManifestExists bool
DeclaredPackages int
Entries []string
}
// CreateResult projects the resolution and optional installation into one
// document. The embedded result carries the shared operation facts; the
// surrounding fields describe the workspace itself.
type CreateResult struct {
install.Result
Workspace string `json:"workspace"`
NonEmpty bool `json:"non_empty"`
ReplacedManifest bool `json:"replaced_manifest,omitempty"`
Installed bool `json:"installed"`
Manifest *documentpkg.Manifest `json:"-"`
Lock *documentpkg.Lock `json:"-"`
}
// CreateResolveError reports a failure before any workspace document was
// written. Workspace may already exist because resolution needs its download
// store.
type CreateResolveError struct {
Workspace string
Product serverpkg.Product
Minecraft version.Version
Selector version.Version
Err error
}
func (e *CreateResolveError) Error() string { return e.Err.Error() }
func (e *CreateResolveError) Unwrap() error { return e.Err }
// CreatePublishError reports a failure while publishing the manifest and lock.
type CreatePublishError struct {
Workspace string
ManifestWritten bool
Err error
}
func (e *CreatePublishError) Error() string { return e.Err.Error() }
func (e *CreatePublishError) Unwrap() error { return e.Err }
// CreateRegistrationError reports that the manifest and lock were published
// but the workspace could not be recorded for the artifact store's retention
// sweep. The documents stay in place so a later attempt can register them.
type CreateRegistrationError struct {
Workspace string
Err error
}
func (e *CreateRegistrationError) Error() string { return e.Err.Error() }
func (e *CreateRegistrationError) Unwrap() error { return e.Err }
// CreateInstallError reports a failure after the workspace was published. The
// manifest and lock stay in place so the installation can be retried.
type CreateInstallError struct {
Workspace string
Err error
}
func (e *CreateInstallError) Error() string { return e.Err.Error() }
func (e *CreateInstallError) Unwrap() error { return e.Err }
// InspectCreateTarget observes path without creating it. Entries carries at
// most five names so a later warning can name what is already there.
func InspectCreateTarget(path string) (CreateTarget, error) {
absolute, err := createAbsolute(path)
if err != nil {
return CreateTarget{}, err
}
target := CreateTarget{Path: absolute}
info, err := os.Stat(absolute)
if errors.Is(err, os.ErrNotExist) {
return target, nil
}
if err != nil {
return target, err
}
if !info.IsDir() {
return target, fmt.Errorf("creation target is not a directory: %s", absolute)
}
target.Exists = true
directory, err := os.Open(absolute)
if err != nil {
return target, err
}
entries, readErr := directory.ReadDir(5)
closeErr := directory.Close()
if readErr != nil && !errors.Is(readErr, io.EOF) && !errors.Is(readErr, os.ErrNotExist) {
return target, errors.Join(readErr, closeErr)
}
if closeErr != nil {
return target, closeErr
}
target.NonEmpty = len(entries) > 0
for _, entry := range entries {
target.Entries = append(target.Entries, entry.Name())
}
_, err = os.Stat(filepath.Join(absolute, documentpkg.ManifestFilename))
switch {
case err == nil:
target.ManifestExists = true
if document, readErr := documentpkg.ManifestFile(absolute).Read(); readErr == nil {
target.DeclaredPackages = declaredPackageCount(document)
}
case errors.Is(err, os.ErrNotExist):
default:
return target, err
}
return target, nil
}
// Create writes a new manifest and lock in opts.Directory, then optionally
// installs the server. It does not prompt; the command boundary owns choices.
func (s *Service) Create(ctx context.Context, opts CreateOptions) (result *CreateResult, resultErr error) {
root, err := createAbsolute(opts.Directory)
if err != nil {
return nil, err
}
target, err := InspectCreateTarget(root)
if err != nil {
return nil, err
}
if target.ManifestExists && !opts.Force {
return nil, fmt.Errorf(
"lucy.yaml exists in %s; pass --force to replace declared intent",
target.Path,
)
}
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, err
}
lease, err := artifactstore.AcquireOperation()
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, lease.Close()) }()
writer, err := workspacewrite.Lock(root, s.logger)
if err != nil {
return nil, err
}
releaseWriter := func() error {
if writer == nil {
return nil
}
closeErr := writer.Close()
writer = nil
return closeErr
}
defer func() { resultErr = errors.Join(resultErr, releaseWriter()) }()
if err := filetrack.RecoverLocked(root, s.logger); err != nil {
return nil, err
}
manifestExisted := false
switch _, err := os.Stat(filepath.Join(root, documentpkg.ManifestFilename)); {
case err == nil:
manifestExisted = true
if !opts.Force {
return nil, fmt.Errorf(
"lucy.yaml exists in %s; pass --force to replace declared intent",
root,
)
}
case errors.Is(err, os.ErrNotExist):
default:
return nil, err
}
var replaced *documentpkg.Manifest
if opts.Force && manifestExisted {
replaced, _ = documentpkg.ManifestFile(root).Read()
}
old, err := documentpkg.LockFile(root).Read()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if errors.Is(err, os.ErrNotExist) {
old = nil
}
name := strings.TrimSpace(opts.Name)
if name == "" {
name = filepath.Base(root)
}
document := newCreateDocument(opts, name)
if err := document.Normalize(); err != nil {
return nil, newCreateResolveError(root, document, err)
}
resolved, resolution, err := s.install.Resolve(
ctx,
root,
document,
old,
install.Options{
AcceptBroken: false,
Trace: opts.Trace,
Materialize: opts.Materialize,
},
)
if err != nil {
return nil, newCreateResolveError(root, document, err)
}
manifestFile := documentpkg.ManifestFile(root)
if opts.Force {
manifestFile = manifestFile.WithoutMerge()
}
if err := manifestFile.Write(document); err != nil {
return nil, &CreatePublishError{Workspace: root, Err: err}
}
publishedLock := resolved != old
if publishedLock {
if err := documentpkg.LockFile(root).Write(resolved); err != nil {
return nil, &CreatePublishError{
Workspace: root,
ManifestWritten: true,
Err: err,
}
}
}
result = &CreateResult{
Workspace: root,
NonEmpty: target.NonEmpty,
ReplacedManifest: manifestExisted,
Manifest: document,
Lock: resolved,
}
if resolution != nil {
result.Result = *resolution
}
result.Schema = install.ResultSchema
result.Kind = CreateKind
if result.Mode == "" {
result.Mode = install.ModeResolve
}
if result.Mode == install.ModeResolve {
result.Reason = "new workspace"
}
result.Server = install.ServerChange{
Change: install.ChangeAdded,
Product: resolved.Server.Product,
Minecraft: resolved.Server.Minecraft,
Version: resolved.Server.CoreVersion,
}
result.TotalPackages = len(resolved.Packages)
result.PublishedManifest = true
result.PublishedLock = publishedLock
if replaced != nil {
if count := declaredPackageCount(replaced); count > 0 {
result.Warnings = append(result.Warnings, fmt.Errorf(
"replaced an existing manifest that declared %s",
packageCountLabel(count),
))
}
}
duplicates, err := s.registerWorkspace(root)
if err != nil {
result.finalizeWarnings()
return result, &CreateRegistrationError{
Workspace: root,
Err: fmt.Errorf("register server: %w", err),
}
}
for _, duplicate := range duplicates {
result.Warnings = append(result.Warnings, fmt.Errorf(
"server name %q is also registered at %s; --server cannot select an ambiguous name",
duplicate.Name,
duplicate.Path,
))
}
if opts.Install {
// Installation acquires the workspace writer lock itself.
if err := releaseWriter(); err != nil {
return result, err
}
installOptions := opts.InstallOptions
installOptions.Trace = opts.Trace
if opts.Materialize != nil {
installOptions.Materialize = opts.Materialize
}
if err := pinWorkspaceDocuments(&installOptions, document, resolved); err != nil {
return result, err
}
installed, installErr := s.install.Sync(
ctx,
root,
document,
resolved,
installOptions,
)
if installed != nil {
result.Files = installed.Files
result.Fetch.Cached += installed.Fetch.Cached
result.Fetch.Downloaded += installed.Fetch.Downloaded
result.Fetch.Bytes += installed.Fetch.Bytes
result.Phases = append(result.Phases, installed.Phases...)
result.Diagnostics = append(result.Diagnostics, installed.Diagnostics...)
result.Java = installed.Java
result.Packages = installed.Packages
result.Warnings = append(result.Warnings, installed.Warnings...)
result.PublishedManifest = result.PublishedManifest || installed.PublishedManifest
result.PublishedLock = result.PublishedLock || installed.PublishedLock
}
if installErr != nil {
if _, committed := errors.AsType[*install.CommittedError](installErr); committed {
result.Installed = true
}
result.finalizeWarnings()
return result, &CreateInstallError{Workspace: root, Err: installErr}
}
result.Installed = true
}
result.finalizeWarnings()
return result, nil
}
func (r *CreateResult) finalizeWarnings() {
seen := make(map[string]struct{}, len(r.Warnings))
unique := r.Warnings[:0]
for _, warning := range r.Warnings {
if _, duplicate := seen[warning.Error()]; duplicate {
continue
}
seen[warning.Error()] = struct{}{}
unique = append(unique, warning)
}
r.Warnings = unique
r.WarningMessages = warningStrings(r.Warnings)
if r.NonEmpty {
r.WarningMessages = append(
r.WarningMessages,
"the target directory was not empty; existing files are left in place",
)
}
}
func newCreateResolveError(
root string,
document *documentpkg.Manifest,
err error,
) *CreateResolveError {
return &CreateResolveError{
Workspace: root,
Product: document.Server.Core.Product,
Minecraft: document.Server.Minecraft,
Selector: document.Server.Core.ProductVersion,
Err: err,
}
}
func newCreateDocument(opts CreateOptions, name string) *documentpkg.Manifest {
product := serverpkg.ProductMinecraft
if productName := strings.TrimSpace(opts.Product); productName != "" {
if parsed, known := serverpkg.ParseProduct(productName); known {
product = parsed
} else {
product = serverpkg.Product(strings.ToLower(productName))
}
}
document := &documentpkg.Manifest{
SchemaVersion: documentpkg.ManifestSchemaVersion,
Name: name,
Server: documentpkg.ManifestServer{
Minecraft: version.Version(strings.TrimSpace(opts.Minecraft)),
Directory: ".",
Core: documentpkg.ManifestCore{
Product: product,
ProductVersion: version.Version(strings.TrimSpace(opts.ProductVersion)),
},
Packages: map[catalog.Ecosystem]map[string]documentpkg.Requirement{},
},
}
if opts.MCDRVersion != "" {
document.MCDR = &documentpkg.ManifestMCDR{
Directory: ".",
Version: version.Version(strings.TrimSpace(opts.MCDRVersion)),
Packages: map[string]documentpkg.Requirement{},
}
}
return document
}
func declaredPackageCount(document *documentpkg.Manifest) int {
if document == nil {
return 0
}
count := 0
for _, packages := range document.Server.Packages {
count += len(packages)
}
if document.MCDR != nil {
count += len(document.MCDR.Packages)
}
return count
}
func warningStrings(warnings []error) []string {
messages := make([]string, 0, len(warnings))
for _, warning := range warnings {
if warning != nil {
messages = append(messages, warning.Error())
}
}
return messages
}
func packageCountLabel(count int) string {
if count == 1 {
return "1 package"
}
return fmt.Sprintf("%d packages", count)
}
// createAbsolute expands a leading home marker before resolving a creation
// path. The interactive form cannot rely on a shell to do it.
func createAbsolute(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "~" || strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
if path == "~" {
path = home
} else {
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
}
return filepath.Abs(path)
}
package operation
import (
"crypto/rand"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/internal/workspacewrite"
)
// DeinitResult reports the Lucy state that deinitialization removed. Paths are
// relative to the server root and listed in removal order.
type DeinitResult struct {
Removed []string
Warnings []error
}
// Deinitialize removes every path that marks root as a Lucy workspace and
// leaves the server it describes in place. Installed server content, worlds,
// and configuration stay untouched. An interrupted installation or file
// transaction is recovered before state is removed, and the writer lock keeps
// another Lucy process from writing while the state disappears.
func (s *Service) Deinitialize(root string) (result *DeinitResult, resultErr error) {
root, err := filepath.Abs(root)
if err != nil {
return nil, err
}
present, err := presentWorkspacePaths(root)
if err != nil {
return nil, err
}
if len(present) == 0 {
return nil, fmt.Errorf("no lucy workspace found in %s", root)
}
writer, err := workspacewrite.Lock(root, s.logger)
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
if err := workspacewrite.RecoverInstall(root, s.logger); err != nil {
return nil, err
}
pending, err := filetrack.HasPendingTransaction(root)
if err != nil {
return nil, err
}
// State that only a pending transaction would touch is about to be
// deleted, so a damaged history alone must not block deinitialization.
if pending {
if err := filetrack.RecoverPending(root); err != nil {
return nil, err
}
}
// Recovery can publish declarations that an interrupted transaction
// restored, so observe the workspace again before removing anything.
present, err = presentWorkspacePaths(root)
if err != nil {
return nil, err
}
result = &DeinitResult{}
for _, name := range []string{
document.ManifestFilename,
document.LockFilename,
} {
if !slices.Contains(present, name) {
continue
}
if err := os.Remove(filepath.Join(root, name)); err != nil {
return result, err
}
result.Removed = append(result.Removed, name)
s.logger.Debug("workspace state removed", "path", name)
}
if s.registry == nil {
result.Warnings = append(
result.Warnings,
errors.New(
"server unregistration unavailable: no server registry was supplied; registered entries for this workspace may remain",
),
)
} else if err := s.registry.Unregister(root); err != nil {
result.Warnings = append(result.Warnings, fmt.Errorf("server unregistration failed: %w", err))
}
detached, err := detachWorkspaceState(root)
if err != nil {
return result, err
}
if err := writer.Close(); err != nil {
return result, err
}
if err := removeDetachedState(root, detached); err != nil {
return result, err
}
result.Removed = append(result.Removed, fsutil.WorkspaceMarker)
s.logger.Info("workspace deinitialized", "root", root, "removed", len(result.Removed))
return result, nil
}
// workspaceStatePaths names every top-level path that marks a Lucy workspace.
func workspaceStatePaths() []string {
return []string{
document.ManifestFilename,
document.LockFilename,
fsutil.WorkspaceMarker,
}
}
// presentWorkspacePaths returns the state paths that exist in root. Symlinks
// and wrong file kinds fail instead of being removed, so a link cannot redirect
// deletion outside the workspace.
func presentWorkspacePaths(root string) ([]string, error) {
present := make([]string, 0, len(workspaceStatePaths()))
for _, name := range workspaceStatePaths() {
if err := workspacewrite.SafePath(root, name); err != nil {
return nil, err
}
info, err := os.Lstat(filepath.Join(root, name))
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
if name == fsutil.WorkspaceMarker {
if !info.IsDir() {
return nil, fmt.Errorf("%s must be a directory", name)
}
} else if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%s must be a regular file", name)
}
present = append(present, name)
}
return present, nil
}
// detachWorkspaceState moves the state directory aside while the writer lock
// is held. A writer that acquires the lock after this rename creates a new
// directory and a new lock file, so removing the detached one cannot pull the
// inode out from under it.
func detachWorkspaceState(root string) (string, error) {
detached := fsutil.WorkspaceMarker + "-removed-" + rand.Text()
if err := os.Rename(
filepath.Join(root, fsutil.WorkspaceMarker),
filepath.Join(root, detached),
); err != nil {
return "", err
}
return detached, nil
}
// removeDetachedState deletes the detached state directory after the writer
// lock is released. Root-confined removal keeps a symlink inside it from
// redirecting the deletion.
func removeDetachedState(root, detached string) (resultErr error) {
files, err := os.OpenRoot(root)
if err != nil {
return err
}
defer func() { resultErr = errors.Join(resultErr, files.Close()) }()
return files.RemoveAll(detached)
}
package operation
import (
"context"
"errors"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/internal/workspacewrite"
"github.com/mclucy/lucy/java"
)
// JavaChange edits declared Java requirements. A nil field stays unchanged.
type JavaChange struct {
// Version is the major version that lucy.yaml pins; zero returns it to
// auto.
Version *int
// Distribution is the distribution that lucy.yaml pins; the unspecified
// distribution returns it to auto.
Distribution *java.Distribution
}
// JavaChangeResult reports the declared Java requirements after a change.
type JavaChangeResult struct {
// Java is the override in lucy.yaml; nil means auto.
Java *document.ManifestJava `json:"java,omitempty"`
PublishedManifest bool `json:"published_manifest"`
}
// SetJava changes only the manifest. Installation reconciles the lock.
func (s *Service) SetJava(ctx context.Context, root string, change JavaChange) (result *JavaChangeResult, resultErr error) {
writer, err := workspacewrite.Lock(root, s.logger)
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
if err := filetrack.RecoverLocked(root, s.logger); err != nil {
return nil, err
}
d, err := document.ManifestFile(root).Read()
if err != nil {
return nil, err
}
result = &JavaChangeResult{}
if change.Version != nil || change.Distribution != nil {
if err := s.setJavaOverride(root, d, change, result); err != nil {
return nil, err
}
}
result.Java = d.Server.Java
return result, nil
}
func (s *Service) setJavaOverride(root string, d *document.Manifest, change JavaChange, result *JavaChangeResult) error {
before, err := d.Fingerprint()
if err != nil {
return err
}
override := document.ManifestJava{}
if d.Server.Java != nil {
override = *d.Server.Java
}
if change.Version != nil {
override.Version = *change.Version
}
if change.Distribution != nil {
override.Distribution = *change.Distribution
}
d.Server.Java = &override
after, err := d.Fingerprint()
if err != nil {
return err
}
if after == before {
s.logger.Debug("java override unchanged", "reason", "lucy.yaml already declares it")
return nil
}
if err := document.ManifestFile(root).Write(d); err != nil {
return err
}
result.PublishedManifest = true
s.logger.Info(
"java override changed",
"version", override.Version,
"distribution", override.Distribution.String(),
)
return nil
}
package operation
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/internal/workspacewrite"
)
// RenameServer changes only the manifest name of the workspace rooted at root.
// It returns the name the manifest carried before the change. Every other
// declaration, the lockfile, and installed server content stay untouched, so
// no dependency or runtime work follows a rename.
func (s *Service) RenameServer(root string, name string) (previous string, resultErr error) {
root, err := filepath.Abs(root)
if err != nil {
return "", err
}
name, err = document.NormalizeName(name)
if err != nil {
return "", err
}
if err := workspacewrite.SafePath(root, document.ManifestFilename); err != nil {
return "", err
}
writer, err := workspacewrite.Lock(root, s.logger)
if err != nil {
return "", err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
if err := workspacewrite.RecoverInstall(root, s.logger); err != nil {
return "", err
}
pending, err := filetrack.HasPendingTransaction(root)
if err != nil {
return "", err
}
if pending {
if err := filetrack.RecoverPending(root); err != nil {
return "", err
}
}
manifest, err := document.ManifestFile(root).Read()
if errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("no lucy workspace found in %s", root)
}
if err != nil {
return "", err
}
previous = manifest.Name
if previous == name {
s.logger.Debug("server name unchanged", "root", root, "name", name)
return previous, nil
}
manifest.Name = name
if err := document.ManifestFile(root).Write(manifest); err != nil {
return "", err
}
s.logger.Info("server renamed", "root", root, "previous", previous, "name", name)
return previous, nil
}
package operation
import (
"context"
"errors"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/serverregistry"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/routing"
"github.com/mclucy/lucy/version"
)
// Source is the configured routing capability that workspace operations
// consume: catalogue builds, artifact mappers, and build identification.
type Source interface {
Candidates(
ctx context.Context,
registry catalog.Registry,
project, minecraft string,
ecosystem catalog.Ecosystem,
) ([]upstream.Candidate, error)
GetArtifactMapper(provider catalog.Provider) (upstream.ArtifactMapProvider, bool, error)
IdentifyFillBuild(
ctx context.Context,
project string,
minecraft version.Version,
sha256 string,
) (version.Version, bool, error)
IdentifyPurpurBuild(
ctx context.Context,
minecraft version.Version,
md5 string,
) (version.Version, bool, error)
ResolveArtifactByHash(
ctx context.Context,
artifact upstream.ArtifactFingerprint,
) routing.ArtifactLookup
}
// Service executes workspace operations with explicit installation, routing,
// registry, and diagnostic dependencies. It never discovers a machine-global
// service of its own.
type Service struct {
install *install.Service
source Source
registry *serverregistry.Registry
logger *log.Logger
}
// NewService constructs the operation service. registry may be nil when the
// machine has no registry location; operations report that only when they
// actually need registration state.
func NewService(
installer *install.Service,
source Source,
registry *serverregistry.Registry,
logger *log.Logger,
) *Service {
return &Service{
install: installer,
source: source,
registry: registry,
logger: logger,
}
}
// registerWorkspace records root for the artifact store's retention sweep.
// Operations that publish a lock must not report success when the entry is
// missing, because the sweep could otherwise reclaim that lock's artifacts.
func (s *Service) registerWorkspace(root string) ([]serverregistry.Entry, error) {
if s.registry == nil {
return nil, errors.New("server registry is unavailable; cannot record this workspace for artifact retention")
}
return s.registry.Register(root)
}
package operation
import (
"context"
"errors"
"os"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/internal/workspacewrite"
)
// Sync converges the workspace to its manifest and lock. A missing manifest or
// lock is allowed only when the other document can drive the operation.
func (s *Service) Sync(ctx context.Context, root string, opts install.Options) (*install.Result, error) {
d, old, err := s.syncDocuments(root)
if err != nil {
return nil, err
}
if err := pinWorkspaceDocuments(&opts, d, old); err != nil {
return nil, err
}
return s.install.Sync(ctx, root, d, old, opts)
}
// syncDocuments reads the documents a converge operation starts from. It takes
// the writer lock and replays interrupted journals first, so the installation
// that re-locks the workspace never plans from pre-recovery state.
func (s *Service) syncDocuments(root string) (manifest *document.Manifest, locked *document.Lock, resultErr error) {
writer, err := workspacewrite.Lock(root, s.logger)
if err != nil {
return nil, nil, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
if err := filetrack.RecoverLocked(root, s.logger); err != nil {
return nil, nil, err
}
manifest, err = document.ManifestFile(root).Read()
if errors.Is(err, os.ErrNotExist) {
manifest, err = nil, nil
}
if err != nil {
return nil, nil, err
}
locked, err = document.LockFile(root).Read()
if errors.Is(err, os.ErrNotExist) {
locked, err = nil, nil
}
if err != nil {
return nil, nil, err
}
return manifest, locked, nil
}
package operation
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/artifactstore"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/filetrack"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/internal/workspacewrite"
"github.com/mclucy/lucy/resolve"
)
const (
defaultUpgradeCombinations = 32
defaultUpgradeVersions = 6
maxUpgradeFailures = 5
)
// UpgradeOptions selects the manifest roots an upgrade may advance. An empty
// Targets upgrades every enabled root.
type UpgradeOptions struct {
Targets []string
// Offline disallows network acquisition. Upgrade cannot discover newer
// versions offline, so this is only useful when it fails early.
Offline bool
Trace *install.Trace
// Materialize supplies artifact bytes for frontier planning. Nil uses the
// configured cache and network.
Materialize func(*catalog.Artifact) (artifact.MaterializedFile, error)
// MaxCombinations and MaxVersions bound the frontier search. Zero uses
// the package defaults.
MaxCombinations int
MaxVersions int
}
// UpgradeMetrics scores one frontier point. UpgradedRoots counts manifest
// roots whose selected build differs from the lock. VersionBumps sums the
// number of newer eligible versions skipped for roots whose version changed.
type UpgradeMetrics struct {
UpgradedRoots int `json:"upgraded_roots"`
VersionBumps int `json:"version_bumps"`
}
// UpgradeOption is one resolved plan and its policy metrics.
type UpgradeOption struct {
Hash string `json:"hash"`
Metrics UpgradeMetrics `json:"metrics"`
Plan *install.Plan `json:"-"`
}
// UpgradeResult carries the bounded Pareto frontier and non-fatal findings.
type UpgradeResult struct {
BaseManifestFingerprint string
BaseLockFingerprint string
Options []UpgradeOption
Warnings []error
}
func (s *Service) PlanUpgrade(
ctx context.Context,
root string,
opts UpgradeOptions,
) (result *UpgradeResult, resultErr error) {
absolute, err := filepath.Abs(root)
if err != nil {
return nil, err
}
root = absolute
d, err := document.ManifestFile(root).Read()
if err != nil {
return nil, err
}
old, err := document.LockFile(root).Read()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("lucy-lock.yaml is required; run lucy install first")
}
return nil, err
}
baseManifest, err := d.Fingerprint()
if err != nil {
return nil, err
}
if old.ManifestFingerprint != baseManifest {
return nil, fmt.Errorf("manifest changed since the lock; run lucy install first")
}
if err := s.upgradePreflight(ctx, root, d, old); err != nil {
return nil, err
}
baseLock, err := old.Fingerprint()
if err != nil {
return nil, err
}
lease, err := artifactstore.AcquireOperation()
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, lease.Close()) }()
writer, err := workspacewrite.Lock(root, s.logger)
if err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, writer.Close()) }()
if err := filetrack.RecoverLocked(root, s.logger); err != nil {
return nil, err
}
currentManifest, err := document.ManifestFile(root).Read()
if err != nil {
return nil, err
}
currentManifestFingerprint, err := currentManifest.Fingerprint()
if err != nil {
return nil, err
}
if currentManifestFingerprint != baseManifest {
return nil, fmt.Errorf("manifest changed during upgrade planning; retry")
}
currentLock, err := document.LockFile(root).Read()
if err != nil {
return nil, err
}
currentLockFingerprint, err := currentLock.Fingerprint()
if err != nil {
return nil, err
}
if currentLockFingerprint != baseLock {
return nil, fmt.Errorf("lock changed during upgrade planning; retry")
}
roots, err := upgradeRoots(d, old)
if err != nil {
return nil, err
}
targets, err := upgradeTargets(roots, opts)
if err != nil {
return nil, err
}
warnings := []error{}
materialize, fetch := s.install.Materializer(ctx, install.Options{
Offline: opts.Offline,
Trace: opts.Trace,
Materialize: opts.Materialize,
})
cache := resolve.NewAnalysisCache()
maxVersions := opts.MaxVersions
if maxVersions <= 0 {
maxVersions = defaultUpgradeVersions
}
active := make([]int, 0, len(roots))
for i := range roots {
if err := ctx.Err(); err != nil {
return nil, err
}
if !targets[roots[i].Reference] {
continue
}
versions, lockedIndex, lockedRank, err := s.upgradeVersions(ctx, roots[i], old, maxVersions)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
if len(opts.Targets) == 0 {
warnings = append(warnings, fmt.Errorf(
"keep %s at %s: %w",
roots[i].Reference,
roots[i].Previous.Version,
err,
))
continue
}
return nil, err
}
roots[i].Versions = versions
roots[i].LockedIndex = lockedIndex
roots[i].LockedRank = lockedRank
if lockedIndex > 0 {
active = append(active, i)
}
}
if err := ctx.Err(); err != nil {
return nil, err
}
if len(active) == 0 {
option, err := noopUpgradeOption(d, old, baseManifest, baseLock)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
return &UpgradeResult{
BaseManifestFingerprint: baseManifest,
BaseLockFingerprint: baseLock,
Options: []UpgradeOption{option},
Warnings: warnings,
}, nil
}
maxCombinations := opts.MaxCombinations
if maxCombinations <= 0 {
maxCombinations = defaultUpgradeCombinations
}
combos := upgradeCombinations(roots, active, maxCombinations)
seen := map[string]UpgradeOption{}
failures := []error{}
for _, combo := range combos {
if err := ctx.Err(); err != nil {
return nil, err
}
option, err := s.resolveUpgradeOption(
ctx,
root,
d,
old,
baseManifest,
baseLock,
roots,
active,
combo,
materialize,
cache,
opts.Trace,
)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
s.logger.Debug("upgrade: candidate combination failed", "error", err)
if len(failures) < maxUpgradeFailures {
failures = append(failures, err)
}
continue
}
seen[option.Hash] = option
}
if err := ctx.Err(); err != nil {
return nil, err
}
if len(seen) == 0 {
if len(failures) == 0 {
return nil, fmt.Errorf("no eligible upgrade found")
}
return nil, fmt.Errorf("no valid upgrade option: %w", errors.Join(failures...))
}
options := make([]UpgradeOption, 0, len(seen))
for _, option := range seen {
if option.Plan != nil && option.Plan.Result != nil {
option.Plan.Result.Fetch = *fetch
}
options = append(options, option)
}
if len(failures) > 0 {
warnings = append(warnings, fmt.Errorf(
"%d candidate combinations failed; showing the resolvable frontier",
len(failures),
))
}
if err := ctx.Err(); err != nil {
return nil, err
}
return &UpgradeResult{
BaseManifestFingerprint: baseManifest,
BaseLockFingerprint: baseLock,
Options: paretoFrontier(options),
Warnings: warnings,
}, nil
}
// ApplyUpgrade applies one selected option through the install transaction.
func (s *Service) ApplyUpgrade(
ctx context.Context,
root string,
option UpgradeOption,
policy string,
opts install.Options,
) (*install.Result, error) {
if option.Plan == nil {
return nil, fmt.Errorf("upgrade option has no plan")
}
option.Plan.Result.Policy = policy
option.Plan.Result.PlanHash = option.Hash
return s.install.ApplyPlan(ctx, root, option.Plan, opts)
}
// SelectUpgradeOption picks a frontier point for a policy value. A policy that
// is not more/later/options is treated as a plan hash.
func SelectUpgradeOption(result *UpgradeResult, policy string) (*UpgradeOption, error) {
if result == nil || len(result.Options) == 0 {
return nil, fmt.Errorf("no upgrade options")
}
switch policy {
case "", "more", "later":
case "options":
return nil, fmt.Errorf("policy options has no single selection")
default:
for i := range result.Options {
if result.Options[i].Hash == policy {
return &result.Options[i], nil
}
}
return nil, fmt.Errorf("plan hash %q was not found; rerun with --dry-run or --policy options", policy)
}
best := &result.Options[0]
for i := range result.Options[1:] {
candidate := &result.Options[i+1]
switch policy {
case "later":
if candidate.Metrics.VersionBumps > best.Metrics.VersionBumps ||
(candidate.Metrics.VersionBumps == best.Metrics.VersionBumps &&
candidate.Metrics.UpgradedRoots > best.Metrics.UpgradedRoots) {
best = candidate
}
default:
if candidate.Metrics.UpgradedRoots > best.Metrics.UpgradedRoots ||
(candidate.Metrics.UpgradedRoots == best.Metrics.UpgradedRoots &&
candidate.Metrics.VersionBumps > best.Metrics.VersionBumps) {
best = candidate
}
}
}
return best, nil
}
// ListUpgradeRoots returns the enabled manifest roots a user can select.
func ListUpgradeRoots(root string) ([]string, error) {
d, err := document.ManifestFile(root).Read()
if err != nil {
return nil, err
}
var refs []string
for _, ecosystem := range sortedEcosystems(d.Server.Packages) {
for ref, requirement := range d.Server.Packages[ecosystem] {
if requirement.IsEnabled() {
refs = append(refs, ref)
}
}
}
if d.MCDR != nil {
for ref, requirement := range d.MCDR.Packages {
if requirement.IsEnabled() {
refs = append(refs, ref)
}
}
}
slices.Sort(refs)
return refs, nil
}
func (s *Service) upgradePreflight(
ctx context.Context,
root string,
d *document.Manifest,
old *document.Lock,
) error {
if d.Server.Minecraft != old.Server.Minecraft {
return fmt.Errorf("Minecraft %s differs from locked %s; run lucy migrate", d.Server.Minecraft, old.Server.Minecraft)
}
if d.Server.Core.Product != old.Server.Product {
return fmt.Errorf("server product %s differs from locked %s; run lucy migrate", d.Server.Core.Product, old.Server.Product)
}
if !upgradeFloating(d.Server.Core.ProductVersion) && d.Server.Core.ProductVersion != old.Server.CoreVersion {
return fmt.Errorf("server core %s differs from locked %s; run lucy migrate", d.Server.Core.ProductVersion, old.Server.CoreVersion)
}
switch {
case d.MCDR == nil && old.MCDR != nil:
return fmt.Errorf("manifest removed MCDR; run lucy install or lucy migrate")
case d.MCDR != nil && old.MCDR == nil:
return fmt.Errorf("manifest added MCDR; run lucy install or lucy migrate")
case d.MCDR != nil && old.MCDR != nil &&
!upgradeFloating(d.MCDR.Version) && d.MCDR.Version != old.MCDR.Version:
return fmt.Errorf("MCDR %s differs from locked %s; run lucy migrate", d.MCDR.Version, old.MCDR.Version)
}
check, err := s.install.CheckJava(ctx, root, old)
if err != nil {
return err
}
if !check.Satisfied() {
if len(check.Problems) > 0 {
return fmt.Errorf("Java runtime does not satisfy the lock: %s", strings.Join(check.Problems, "; "))
}
return fmt.Errorf("Java %d is required; run lucy install --java", check.Target)
}
return nil
}
package operation
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"slices"
"sort"
"strings"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/install"
"github.com/mclucy/lucy/resolve"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/routing"
versionpkg "github.com/mclucy/lucy/version"
)
func (s *Service) resolveUpgradeOption(
ctx context.Context,
root string,
d *document.Manifest,
old *document.Lock,
baseManifest string,
baseLock string,
roots []upgradeRoot,
active []int,
combo []int,
materialize func(*catalog.Artifact) (artifact.MaterializedFile, error),
cache *resolve.AnalysisCache,
trace *install.Trace,
) (UpgradeOption, error) {
resolutionManifest, err := cloneManifest(d)
if err != nil {
return UpgradeOption{}, err
}
pinCoreAndMCDR(resolutionManifest, old)
choices := map[string]versionpkg.Version{}
for i, rootIndex := range active {
root := roots[rootIndex]
choices[root.Reference] = root.Versions[combo[i]].Version
}
for _, root := range roots {
selector := root.Requirement.Selector
if choice, ok := choices[root.Reference]; ok {
selector = choice
} else if root.Previous != nil {
selector = upgradeBuildSelector(upstream.Candidate{Artifact: root.Previous.Artifact})
}
if err := setRootSelector(resolutionManifest, root, selector); err != nil {
return UpgradeOption{}, err
}
}
if err := resolutionManifest.Normalize(); err != nil {
return UpgradeOption{}, err
}
coreModules, moduleErr := routing.CoreModules(
ctx,
old.Server.Product,
old.Server.Artifact,
materialize,
)
if moduleErr != nil {
s.logger.Warn(fmt.Errorf("read server core modules: %w", moduleErr))
}
packages, _, err := resolve.Packages(resolve.PackagesRequest{
Context: ctx,
Manifest: resolutionManifest,
Previous: old.Packages,
Target: upgradeTarget(old, coreModules),
Candidates: s.source.Candidates,
Materialize: materialize,
ExpandDependencies: true,
Cache: cache,
Logger: s.logger,
Trace: upgradeResolveTrace(trace),
})
if err != nil {
return UpgradeOption{}, err
}
finalManifest, err := cloneManifest(d)
if err != nil {
return UpgradeOption{}, err
}
var manifestChanges []install.ManifestChange
for _, rootIndex := range active {
root := roots[rootIndex]
selected := selectedRootPackage(packages, root)
if selected == nil {
return UpgradeOption{}, fmt.Errorf("resolution did not select %s", root.Reference)
}
if atLeast, comparable := upgradeVersionAtLeast(
selected.Version,
root.Previous.Version,
upgradeArtifactDialect(root.Ecosystem),
); comparable && !atLeast {
return UpgradeOption{}, fmt.Errorf(
"selected %s %s downgrades locked %s",
root.Reference,
selected.Version,
root.Previous.Version,
)
}
if root.Requirement.Selector.CanInfer() || root.Requirement.Selector == "" {
continue
}
if selected.Version == root.Requirement.Selector {
continue
}
if err := setRootSelector(finalManifest, root, selected.Version); err != nil {
return UpgradeOption{}, err
}
manifestChanges = append(manifestChanges, install.ManifestChange{
Reference: root.Reference,
PreviousSelector: root.Requirement.Selector,
Selector: selected.Version,
})
}
if err := finalManifest.Normalize(); err != nil {
return UpgradeOption{}, err
}
requirements := rootRequirements(finalManifest)
for i := range packages {
for j := range packages[i].Requirements {
if requirement, ok := requirements[packages[i].Requirements[j].Reference]; ok {
packages[i].Requirements[j].Selector = requirement.Selector
packages[i].Requirements[j].FilePattern = requirement.FilePattern
packages[i].Requirements[j].Enabled = requirement.IsEnabled()
}
}
}
finalFingerprint, err := finalManifest.Fingerprint()
if err != nil {
return UpgradeOption{}, err
}
resolved := &document.Lock{
SchemaVersion: document.LockSchemaVersion,
ManifestFingerprint: finalFingerprint,
Server: old.Server,
MCDR: old.MCDR,
Packages: packages,
Files: slices.Clone(finalManifest.Files),
}
if err := resolved.Validate(); err != nil {
return UpgradeOption{}, err
}
if fingerprint, err := resolved.Fingerprint(); err == nil && fingerprint == baseLock {
resolved = old
}
metrics := UpgradeMetrics{}
for _, rootIndex := range active {
root := roots[rootIndex]
selected := selectedRootPackage(resolved.Packages, root)
if selected == nil {
continue
}
changed := selected.Version != root.Previous.Version ||
selected.ReleaseID != root.Previous.ReleaseID ||
selected.FileID != root.Previous.FileID
if changed {
metrics.UpgradedRoots++
}
for _, version := range root.Versions {
if version.Version == selected.Version {
if version.Rank < root.LockedRank {
metrics.VersionBumps += root.LockedRank - version.Rank
}
break
}
}
}
publishManifest := finalFingerprint != baseManifest
plan, err := install.PlanFromResolution(finalManifest, old, resolved, baseManifest, publishManifest)
if err != nil {
return UpgradeOption{}, err
}
plan.Result.ManifestChanges = manifestChanges
hash := upgradePlanHash(baseManifest, baseLock, finalFingerprint, resolved.Packages)
plan.Result.PlanHash = hash
return UpgradeOption{
Hash: hash,
Metrics: metrics,
Plan: plan,
}, nil
}
func upgradeBuildSelector(candidate upstream.Candidate) versionpkg.Version {
if candidate.ReleaseID != "" {
return versionpkg.Version(candidate.ReleaseID)
}
return candidate.Version
}
func upgradeResolveTrace(trace *install.Trace) resolve.Trace {
if trace == nil {
return resolve.Trace{}
}
return trace.Resolve
}
func selectedRootPackage(packages []document.Package, root upgradeRoot) *document.Package {
for i := range packages {
p := &packages[i]
if p.Environment != root.Environment || p.Ecosystem != root.Ecosystem {
continue
}
for _, binding := range p.Requirements {
if binding.Reference == root.Reference {
return p
}
}
}
return nil
}
func noopUpgradeOption(
d *document.Manifest,
old *document.Lock,
baseManifest string,
baseLock string,
) (UpgradeOption, error) {
plan, err := install.PlanFromResolution(d, old, old, baseManifest, false)
if err != nil {
return UpgradeOption{}, err
}
hash := upgradePlanHash(baseManifest, baseLock, old.ManifestFingerprint, old.Packages)
plan.Result.PlanHash = hash
return UpgradeOption{
Hash: hash,
Plan: plan,
}, nil
}
func upgradePlanHash(
baseManifest string,
baseLock string,
finalManifest string,
packages []document.Package,
) string {
lines := []string{
"v1",
"base-manifest:" + baseManifest,
"base-lock:" + baseLock,
"final-manifest:" + finalManifest,
}
sorted := slices.Clone(packages)
sort.Slice(sorted, func(i, j int) bool {
a, b := &sorted[i], &sorted[j]
if n := strings.Compare(string(a.Environment), string(b.Environment)); n != 0 {
return n < 0
}
if n := strings.Compare(string(a.Ecosystem), string(b.Ecosystem)); n != 0 {
return n < 0
}
if n := strings.Compare(string(a.Provider), string(b.Provider)); n != 0 {
return n < 0
}
return strings.Compare(a.ProjectID, b.ProjectID) < 0
})
for _, p := range sorted {
lines = append(lines, strings.Join([]string{
"pkg",
string(p.Environment),
string(p.Ecosystem),
string(p.Provider),
p.ProjectID,
string(p.Version),
p.ReleaseID,
p.FileID,
}, "|"))
}
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return "sha256:" + hex.EncodeToString(sum[:])
}
func paretoFrontier(options []UpgradeOption) []UpgradeOption {
sort.Slice(options, func(i, j int) bool {
a, b := &options[i], &options[j]
if a.Metrics.UpgradedRoots != b.Metrics.UpgradedRoots {
return b.Metrics.UpgradedRoots < a.Metrics.UpgradedRoots
}
if a.Metrics.VersionBumps != b.Metrics.VersionBumps {
return b.Metrics.VersionBumps < a.Metrics.VersionBumps
}
return strings.Compare(a.Hash, b.Hash) < 0
})
frontier := make([]UpgradeOption, 0, len(options))
maxBumps := -1
for _, option := range options {
if option.Metrics.VersionBumps <= maxBumps {
continue
}
frontier = append(frontier, option)
maxBumps = option.Metrics.VersionBumps
}
return frontier
}
package operation
import (
"bytes"
"fmt"
"slices"
"sort"
"strings"
"time"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/resolve"
serverpkg "github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
type upgradeRoot struct {
Environment serverpkg.Environment
Ecosystem catalog.Ecosystem
Reference string
Requirement document.Requirement
Provider catalog.Provider
Project string
Previous *document.Package
Versions []upgradeVersion
LockedIndex int
LockedRank int
}
type upgradeVersion struct {
Version versionpkg.Version
Builds []upstream.Candidate
Rank int
PublishedAt time.Time
}
// PlanUpgrade resolves candidate frontiers without staging or committing.
func upgradeFloating(v versionpkg.Version) bool {
switch v {
case "", versionpkg.Any, versionpkg.Beta, versionpkg.Stable, "latest":
return true
default:
return false
}
}
func upgradeRoots(d *document.Manifest, old *document.Lock) ([]upgradeRoot, error) {
var roots []upgradeRoot
for _, ecosystem := range sortedEcosystems(d.Server.Packages) {
for ref, requirement := range d.Server.Packages[ecosystem] {
if !requirement.IsEnabled() {
continue
}
root, err := newUpgradeRoot(serverpkg.EnvironmentServer, ecosystem, ref, requirement, old)
if err != nil {
return nil, err
}
roots = append(roots, root)
}
}
if d.MCDR != nil {
for ref, requirement := range d.MCDR.Packages {
if !requirement.IsEnabled() {
continue
}
root, err := newUpgradeRoot(serverpkg.EnvironmentMCDR, catalog.EcosystemMCDR, ref, requirement, old)
if err != nil {
return nil, err
}
roots = append(roots, root)
}
}
sort.Slice(roots, func(i, j int) bool {
if n := strings.Compare(string(roots[i].Environment), string(roots[j].Environment)); n != 0 {
return n < 0
}
if n := strings.Compare(string(roots[i].Ecosystem), string(roots[j].Ecosystem)); n != 0 {
return n < 0
}
return roots[i].Reference < roots[j].Reference
})
return roots, nil
}
func newUpgradeRoot(
environment serverpkg.Environment,
ecosystem catalog.Ecosystem,
ref string,
requirement document.Requirement,
old *document.Lock,
) (upgradeRoot, error) {
provider, project, err := document.ParseReference(ref)
if err != nil {
return upgradeRoot{}, err
}
previous := previousRootPackage(old, environment, ecosystem, ref, provider, project)
if previous == nil {
return upgradeRoot{}, fmt.Errorf("lock has no package for manifest root %s; run lucy install first", ref)
}
return upgradeRoot{
Environment: environment,
Ecosystem: ecosystem,
Reference: ref,
Requirement: requirement,
Provider: provider,
Project: project,
Previous: previous,
}, nil
}
func previousRootPackage(
old *document.Lock,
environment serverpkg.Environment,
ecosystem catalog.Ecosystem,
ref string,
provider catalog.Provider,
project string,
) *document.Package {
for i := range old.Packages {
p := &old.Packages[i]
if p.Environment != environment || p.Ecosystem != ecosystem {
continue
}
for _, binding := range p.Requirements {
if binding.Reference == ref {
return p
}
}
}
for i := range old.Packages {
p := &old.Packages[i]
if p.Environment == environment && p.Ecosystem == ecosystem &&
p.Provider == provider && p.ProjectID == project {
return p
}
}
return nil
}
func upgradeTargets(roots []upgradeRoot, opts UpgradeOptions) (map[string]bool, error) {
if len(opts.Targets) == 0 {
targets := make(map[string]bool, len(roots))
for _, root := range roots {
targets[root.Reference] = true
}
return targets, nil
}
targets := map[string]bool{}
for _, raw := range opts.Targets {
request, err := input.Parse(raw)
if err != nil {
return nil, err
}
if request.Version != "" && request.Version != versionpkg.Any {
return nil, fmt.Errorf("upgrade targets name packages, not versions: %s", raw)
}
var matched []string
for _, root := range roots {
if request.Provider != catalog.ProviderUnspecified && request.Provider != root.Provider {
continue
}
if !strings.EqualFold(string(request.Name), root.Project) {
continue
}
matched = append(matched, root.Reference)
}
switch len(matched) {
case 0:
return nil, fmt.Errorf("package %s is not an enabled manifest root", raw)
case 1:
targets[matched[0]] = true
default:
return nil, fmt.Errorf("package %s is ambiguous: %s", raw, strings.Join(matched, ", "))
}
}
return targets, nil
}
func pinCoreAndMCDR(d *document.Manifest, old *document.Lock) {
d.Server.Minecraft = old.Server.Minecraft
d.Server.Core.Product = old.Server.Product
d.Server.Core.ProductVersion = old.Server.CoreVersion
if d.MCDR != nil && old.MCDR != nil {
d.MCDR.Version = old.MCDR.Version
}
}
func upgradeTarget(old *document.Lock, coreModules []catalog.DescriptorModule) resolve.Target {
mcdrVersion := ""
if old.MCDR != nil {
mcdrVersion = string(old.MCDR.Version)
}
return resolve.Target{
Product: old.Server.Product,
Bootstrapper: old.Server.Bootstrapper,
Minecraft: old.Server.Minecraft,
CoreVersion: old.Server.CoreVersion,
MCDRVersion: mcdrVersion,
CoreModules: coreModules,
}
}
func rootRequirements(d *document.Manifest) map[string]document.Requirement {
requirements := map[string]document.Requirement{}
for _, ecosystem := range sortedEcosystems(d.Server.Packages) {
for ref, requirement := range d.Server.Packages[ecosystem] {
requirements[ref] = requirement
}
}
if d.MCDR != nil {
for ref, requirement := range d.MCDR.Packages {
requirements[ref] = requirement
}
}
return requirements
}
func setRootSelector(d *document.Manifest, root upgradeRoot, selector versionpkg.Version) error {
if root.Environment == serverpkg.EnvironmentMCDR {
if d.MCDR == nil {
return fmt.Errorf("manifest has no MCDR runtime")
}
requirement, ok := d.MCDR.Packages[root.Reference]
if !ok {
return fmt.Errorf("manifest root %s is missing", root.Reference)
}
requirement.Selector = selector
d.MCDR.Packages[root.Reference] = requirement
return nil
}
packages, ok := d.Server.Packages[root.Ecosystem]
if !ok {
return fmt.Errorf("manifest ecosystem %s is missing", root.Ecosystem)
}
requirement, ok := packages[root.Reference]
if !ok {
return fmt.Errorf("manifest root %s is missing", root.Reference)
}
requirement.Selector = selector
packages[root.Reference] = requirement
return nil
}
func cloneManifest(d *document.Manifest) (*document.Manifest, error) {
data, err := d.CanonicalBytes()
if err != nil {
return nil, err
}
return document.DecodeManifest(bytes.NewReader(data))
}
func sortedEcosystems(packages map[catalog.Ecosystem]map[string]document.Requirement) []catalog.Ecosystem {
ecosystems := make([]catalog.Ecosystem, 0, len(packages))
for ecosystem := range packages {
ecosystems = append(ecosystems, ecosystem)
}
slices.Sort(ecosystems)
return ecosystems
}
package operation
import (
"context"
"fmt"
"path/filepath"
"slices"
"sort"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
func (s *Service) upgradeVersions(
ctx context.Context,
root upgradeRoot,
old *document.Lock,
maxVersions int,
) ([]upgradeVersion, int, int, error) {
registry, ok := root.Provider.Registry()
if !ok {
return nil, 0, 0, fmt.Errorf("provider %s has no package catalogue", root.Provider)
}
list, err := s.source.Candidates(
ctx,
registry,
root.Project,
string(old.Server.Minecraft),
root.Ecosystem,
)
if err != nil {
return nil, 0, 0, err
}
selector := root.Requirement.Selector
artifactEligible := make([]upstream.Candidate, 0, len(list))
for _, candidate := range list {
if upgradeLockedBuild(candidate, root.Previous) {
artifactEligible = append(artifactEligible, candidate)
continue
}
if root.Requirement.FilePattern != "" {
match, err := filepath.Match(root.Requirement.FilePattern, candidate.Filename)
if err != nil {
return nil, 0, 0, err
}
if !match {
continue
}
} else if !candidate.PrimaryFile {
continue
}
artifactEligible = append(artifactEligible, candidate)
}
var lockedCandidate *upstream.Candidate
for i := range artifactEligible {
if upgradeLockedBuild(artifactEligible[i], root.Previous) {
lockedCandidate = &artifactEligible[i]
break
}
}
if lockedCandidate == nil {
return nil, 0, 0, fmt.Errorf("locked version %s is no longer listed", root.Previous.Version)
}
dialect := upgradeArtifactDialect(root.Ecosystem)
withoutDowngrades := make([]upstream.Candidate, 0, len(artifactEligible))
for _, candidate := range artifactEligible {
if upgradeLockedBuild(candidate, root.Previous) {
withoutDowngrades = append(withoutDowngrades, candidate)
continue
}
if atLeast, comparable := upgradeVersionAtLeast(candidate.Version, lockedCandidate.Version, dialect); comparable {
if !atLeast {
continue
}
} else if !candidate.PublishedAt.IsZero() && !lockedCandidate.PublishedAt.IsZero() &&
candidate.PublishedAt.Before(lockedCandidate.PublishedAt) {
continue
}
withoutDowngrades = append(withoutDowngrades, candidate)
}
hasRelease := false
for _, candidate := range withoutDowngrades {
if candidate.Channel == upstream.ReleaseChannelRelease {
hasRelease = true
break
}
}
eligible := make([]upstream.Candidate, 0, len(withoutDowngrades))
for _, candidate := range withoutDowngrades {
locked := upgradeLockedBuild(candidate, root.Previous)
if !locked && selector == versionpkg.Stable && candidate.Channel != upstream.ReleaseChannelRelease {
continue
}
if !locked &&
(selector == versionpkg.Any || selector == "" || !selector.CanInfer()) &&
hasRelease && candidate.Channel != upstream.ReleaseChannelRelease {
continue
}
eligible = append(eligible, candidate)
}
if len(eligible) == 0 {
return nil, 0, 0, fmt.Errorf("no eligible builds for Minecraft %s", old.Server.Minecraft)
}
byVersion := map[versionpkg.Version][]upstream.Candidate{}
for _, candidate := range eligible {
byVersion[candidate.Version] = append(byVersion[candidate.Version], candidate)
}
versions := make([]upgradeVersion, 0, len(byVersion))
for version, builds := range byVersion {
sort.SliceStable(builds, func(i, j int) bool {
a, b := &builds[i], &builds[j]
if n := b.PublishedAt.Compare(a.PublishedAt); n != 0 {
return n < 0
}
if n := strings.Compare(a.ReleaseID, b.ReleaseID); n != 0 {
return n < 0
}
return a.FileID < b.FileID
})
versions = append(versions, upgradeVersion{
Version: version,
Builds: builds,
PublishedAt: builds[0].PublishedAt,
})
}
sort.Slice(versions, func(i, j int) bool {
a, b := &versions[i], &versions[j]
if n := b.PublishedAt.Compare(a.PublishedAt); n != 0 {
return n < 0
}
return string(b.Version) < string(a.Version)
})
lockedRank := -1
for i := range versions {
versions[i].Rank = i
if versions[i].Version == root.Previous.Version {
lockedRank = i
continue
}
for _, build := range versions[i].Builds {
if build.ReleaseID == root.Previous.ReleaseID && build.FileID == root.Previous.FileID {
lockedRank = i
}
}
}
if lockedRank < 0 {
return nil, 0, 0, fmt.Errorf("locked version %s is no longer listed", root.Previous.Version)
}
trimmed := versions
lockedIndex := lockedRank
if len(versions) > maxVersions {
if lockedRank < maxVersions {
trimmed = versions[:maxVersions]
lockedIndex = lockedRank
} else {
trimmed = append([]upgradeVersion{}, versions[:maxVersions-1]...)
trimmed = append(trimmed, versions[lockedRank])
lockedIndex = len(trimmed) - 1
}
}
return trimmed, lockedIndex, lockedRank, nil
}
func upgradeCombinations(roots []upgradeRoot, active []int, limit int) [][]int {
locked := make([]int, len(active))
for i, rootIndex := range active {
locked[i] = roots[rootIndex].LockedIndex
}
seeds := []int{}
for range active {
seeds = append(seeds, 0)
}
queue := [][]int{seeds, locked}
seen := map[string]bool{}
var combos [][]int
for len(queue) > 0 && len(combos) < limit {
combo := queue[0]
queue = queue[1:]
key := fmt.Sprint(combo)
if seen[key] {
continue
}
seen[key] = true
combos = append(combos, combo)
for i, rootIndex := range active {
if combo[i]+1 > roots[rootIndex].LockedIndex {
continue
}
next := slices.Clone(combo)
next[i]++
queue = append(queue, next)
}
}
slices.SortFunc(combos, func(a, b []int) int {
sumA, sumB := 0, 0
for i := range a {
sumA += a[i]
sumB += b[i]
}
if sumA != sumB {
return sumA - sumB
}
for i := range a {
if a[i] != b[i] {
return a[i] - b[i]
}
}
return 0
})
return combos
}
func upgradeLockedBuild(candidate upstream.Candidate, previous *document.Package) bool {
if previous == nil {
return false
}
if previous.ReleaseID != "" || previous.FileID != "" {
return candidate.ReleaseID == previous.ReleaseID && candidate.FileID == previous.FileID
}
return candidate.Version == previous.Version
}
func upgradeArtifactDialect(ecosystem catalog.Ecosystem) versionpkg.Dialect {
switch ecosystem {
case catalog.EcosystemBukkit, catalog.EcosystemPaper:
return versionpkg.DialectOpaque
default:
return versionpkg.DialectMaven
}
}
func upgradeVersionAtLeast(
candidate, locked versionpkg.Version,
dialect versionpkg.Dialect,
) (bool, bool) {
if !dialect.Valid() || dialect == versionpkg.DialectOpaque {
return false, false
}
parsedCandidate, err := versionpkg.ParseVersion(candidate, dialect)
if err != nil {
return false, false
}
parsedLocked, err := versionpkg.ParseVersion(locked, dialect)
if err != nil {
return false, false
}
comparison, comparable := versionpkg.Compare(parsedCandidate, parsedLocked)
if !comparable {
return false, false
}
return comparison >= 0, true
}
// Package report presents warnings and errors on a caller-owned stream,
// independently of diagnostic logging.
package report
import (
"image/color"
"io"
"strings"
"charm.land/lipgloss/v2"
"charm.land/log/v2"
"github.com/mclucy/lucy/terminal/style"
)
// Reporter presents one invocation's notices on the stream it owns.
type Reporter struct {
logger *log.Logger
}
// New builds a reporter that writes level-prefixed notices to w. Machine
// modes pass io.Discard so no human notice joins their documents.
func New(w io.Writer, theme style.Theme) *Reporter {
logger := log.NewWithOptions(w, log.Options{Level: log.InfoLevel})
logger.SetStyles(levelStyles(theme.Colors()))
return &Reporter{logger: logger}
}
// Info presents a non-fatal observation.
func (r *Reporter) Info(message string) {
r.logger.Info(message)
}
// Warn presents a recoverable failure.
func (r *Reporter) Warn(err error) {
if err != nil {
r.logger.Warn(err.Error())
}
}
// Error presents a failure.
func (r *Reporter) Error(err error) {
if err != nil {
r.logger.Error(err.Error())
}
}
func levelStyles(colors bool) *log.Styles {
levelStyle := func(name string, foreground color.Color) lipgloss.Style {
level := lipgloss.NewStyle().
SetString(strings.ToUpper(name)).
Bold(true).
MaxWidth(5)
if colors {
level = level.Foreground(foreground)
}
return level
}
styles := log.DefaultStyles()
styles.Levels[log.InfoLevel] = levelStyle(log.InfoLevel.String(), lipgloss.Green)
styles.Levels[log.WarnLevel] = levelStyle(log.WarnLevel.String(), lipgloss.Yellow)
styles.Levels[log.ErrorLevel] = levelStyle(log.ErrorLevel.String(), lipgloss.Red)
styles.Levels[log.FatalLevel] = levelStyle(log.FatalLevel.String(), lipgloss.Red)
return styles
}
package report
import (
"fmt"
"io"
"github.com/mclucy/lucy/terminal/style"
)
// Detail is a multi-line failure that the command boundary chooses to render
// instead of the framework's single-line box. Cause preserves the chain.
type Detail struct {
Title string
Lines []string
Hint string
// Code is a stable machine-readable failure code. Human rendering ignores
// it; commands that emit JSON carry it into the error document.
Code string
Cause error
}
func (d *Detail) Error() string {
if d == nil {
return ""
}
return d.Title
}
func (d *Detail) Unwrap() error {
if d == nil {
return nil
}
return d.Cause
}
// RenderDetail writes one detail to w with the supplied theme. Every surface
// that reports failures uses it, so a failure reads the same wherever it
// appears.
func RenderDetail(w io.Writer, detail *Detail, theme style.Theme) {
if detail == nil {
return
}
_, _ = fmt.Fprintf(w, "%s %s\n", theme.Failure("error:"), detail.Title)
for _, line := range detail.Lines {
_, _ = fmt.Fprintf(w, " %s %s\n", theme.Muted("│"), line)
}
if detail.Hint != "" {
_, _ = fmt.Fprintf(w, " %s %s\n", theme.Note("hint:"), detail.Hint)
}
}
package resolve
import (
"path/filepath"
"slices"
"sort"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
// candidateDecision records the first reason a candidate cannot answer a
// request. A later rule may also fail; the first failure is the one reported.
type candidateDecision struct {
eligible bool
kind RejectionKind
detail string
reason string
}
// decideCandidate applies the request's hard filters to one candidate.
func decideCandidate(
candidate upstream.Candidate,
request projectRequest,
) (candidateDecision, error) {
decision := candidateDecision{eligible: true}
reject := func(kind RejectionKind, detail string) {
decision.eligible = false
if decision.reason == "" {
decision.reason = describeRejection(kind, detail)
decision.kind = kind
decision.detail = detail
}
}
// Remote release hints are advisory. They order candidates, but a build
// the dependent did not name can still satisfy the dependency.
constrained := false
for _, requirement := range request.requirements {
if !requirement.Enabled {
continue
}
constrained = true
switch requirement.Selector {
case versionpkg.Stable:
if candidate.Channel != upstream.ReleaseChannelRelease {
reject(RejectionChannel, "requested a release build")
}
case versionpkg.Any, versionpkg.Beta, "":
default:
if candidate.Version != requirement.Selector &&
candidate.ReleaseID != string(requirement.Selector) &&
candidate.FileID != string(requirement.Selector) &&
candidate.Filename != string(requirement.Selector) {
reject(RejectionVersionMismatch, string(requirement.Selector))
}
}
if requirement.FilePattern != "" {
match, err := filepath.Match(requirement.FilePattern, candidate.Filename)
if err != nil {
return decision, err
}
if !match {
reject(RejectionFilePattern, requirement.FilePattern)
}
} else if !candidate.PrimaryFile {
reject(RejectionPrimaryFile, "")
}
}
// A dependency request carries no requirement of its own, so no enabled
// binding rejects the extra files a release publishes: the sources and dev
// jars carry a readable descriptor and would otherwise win the version race
// on publication date alone.
if !constrained && !candidate.PrimaryFile {
reject(RejectionPrimaryFile, "")
}
return decision, nil
}
// orderCandidates applies the cache order: newest publication first, then the
// previously locked build. The release-channel preference depends on each
// request's selectors, so the caller applies it after reading the cache.
func orderCandidates(
list []upstream.Candidate,
previous []document.Package,
request projectRequest,
) []upstream.Candidate {
ordered := slices.Clone(list)
sort.SliceStable(ordered, func(i, j int) bool {
return ordered[i].PublishedAt.After(ordered[j].PublishedAt)
})
for _, p := range previous {
if p.Environment != request.key.environment ||
p.Ecosystem != request.key.ecosystem ||
p.Provider != request.key.provider ||
p.ProjectID != request.key.project {
continue
}
for i, candidate := range ordered {
if candidate.ReleaseID == p.ReleaseID && candidate.FileID == p.FileID {
ordered = append(
[]upstream.Candidate{candidate},
append(ordered[:i], ordered[i+1:]...)...,
)
break
}
}
}
return ordered
}
// preferReleaseBuilds applies the default selector's preference: `any` takes
// the newest build but prefers a release over a prerelease when both exist.
// `beta` keeps publication order because it accepts prereleases outright, and
// `stable` rejects prereleases in the candidate loop.
func preferReleaseBuilds(
list []upstream.Candidate,
requirements []document.RequirementBinding,
) []upstream.Candidate {
prefer := false
for _, requirement := range requirements {
if !requirement.Enabled {
continue
}
if requirement.Selector == versionpkg.Any || requirement.Selector == "" {
prefer = true
}
}
if !prefer {
return list
}
ordered := make([]upstream.Candidate, 0, len(list))
for _, candidate := range list {
if candidate.Channel == upstream.ReleaseChannelRelease {
ordered = append(ordered, candidate)
}
}
for _, candidate := range list {
if candidate.Channel != upstream.ReleaseChannelRelease {
ordered = append(ordered, candidate)
}
}
return ordered
}
// preferReleaseHints moves candidates named by remote dependency metadata to
// the front of the candidate list. The hints are advisory: a different build
// can still satisfy the dependency.
func preferReleaseHints(
list []upstream.Candidate,
hints []releaseHint,
) []upstream.Candidate {
hinted := map[string]bool{}
for _, hint := range hints {
if hint.releaseID != "" {
hinted[hint.releaseID] = true
}
}
if len(hinted) == 0 {
return list
}
ordered := make([]upstream.Candidate, 0, len(list))
for _, candidate := range list {
if hinted[candidate.ReleaseID] {
ordered = append(ordered, candidate)
}
}
for _, candidate := range list {
if !hinted[candidate.ReleaseID] {
ordered = append(ordered, candidate)
}
}
return ordered
}
// conflictDependencies returns the candidate's incompatibility declarations.
func conflictDependencies(candidate upstream.Candidate) []upstream.ProjectDependency {
var out []upstream.ProjectDependency
for _, dep := range candidate.Dependencies {
switch dep.Kind {
case catalog.DependencyKindIncompatible, catalog.DependencyKindConflict:
out = append(out, dep)
}
}
return out
}
// metadataConflict reports the first metadata-declared incompatibility between
// candidate and the selected packages. It checks both directions so a one-sided
// declaration is not selection-order dependent.
func metadataConflict(
candidate upstream.Candidate,
request projectRequest,
chosen map[projectKey]document.Package,
conflicts map[projectKey][]upstream.ProjectDependency,
) (string, bool) {
best := ""
note := func(detail string) {
if best == "" || strings.Compare(detail, best) < 0 {
best = detail
}
}
for _, dep := range conflictDependencies(candidate) {
if dep.Provider == candidate.Provider && dep.ProjectID == candidate.ProjectID {
continue
}
for key, selected := range chosen {
if key.environment != request.key.environment || key.ecosystem != request.key.ecosystem {
continue
}
if selected.Artifact.Filename == "" ||
selected.Provider != dep.Provider ||
selected.ProjectID != dep.ProjectID {
continue
}
if dep.ReleaseID != "" && selected.ReleaseID != dep.ReleaseID {
continue
}
note(conflictDetail(dep))
}
}
for key, selectedConflicts := range conflicts {
if key.environment != request.key.environment || key.ecosystem != request.key.ecosystem {
continue
}
for _, dep := range selectedConflicts {
if dep.Provider != candidate.Provider || dep.ProjectID != candidate.ProjectID {
continue
}
if dep.ReleaseID != "" && candidate.ReleaseID != dep.ReleaseID {
continue
}
note(conflictDetail(dep))
}
}
return best, best != ""
}
func conflictDetail(dep upstream.ProjectDependency) string {
detail := dep.Provider.String() + ":" + dep.ProjectID
if dep.ReleaseID != "" {
detail += "@" + dep.ReleaseID
}
return detail
}
type analysisEntry struct {
modules []catalog.DescriptorModule
hashes map[string]string
}
// AnalysisCache reuses artifact analysis within one resolution session.
type AnalysisCache struct {
entries map[string]analysisEntry
}
func NewAnalysisCache() *AnalysisCache {
return &AnalysisCache{entries: map[string]analysisEntry{}}
}
func (c *AnalysisCache) get(key string) (analysisEntry, bool) {
if c == nil {
return analysisEntry{}, false
}
entry, ok := c.entries[key]
if !ok {
return analysisEntry{}, false
}
return analysisEntry{
modules: cloneModules(entry.modules),
hashes: entry.hashes,
}, true
}
func (c *AnalysisCache) put(key string, entry analysisEntry) {
if c == nil {
return
}
c.entries[key] = analysisEntry{
modules: cloneModules(entry.modules),
hashes: entry.hashes,
}
}
func candidateAnalysisKey(candidate upstream.Candidate) string {
return string(candidate.Provider) + "|" +
candidate.ProjectID + "|" +
candidate.ReleaseID + "|" +
candidate.FileID + "|" +
candidate.URL
}
func cloneModules(modules []catalog.DescriptorModule) []catalog.DescriptorModule {
if len(modules) == 0 {
return nil
}
out := make([]catalog.DescriptorModule, len(modules))
for i, module := range modules {
out[i] = module
out[i].Provides = slices.Clone(module.Provides)
out[i].Dependencies = slices.Clone(module.Dependencies)
}
return out
}
package resolve
import (
"fmt"
"slices"
"sort"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/server"
versionpkg "github.com/mclucy/lucy/version"
)
// ModuleProblem names the module requirement that a candidate cannot satisfy,
// together with the package that ships the module. The owner lets the failure
// ledger attribute the problem to the package that caused it instead of the
// last package that happened to be selected.
type ModuleProblem struct {
Environment server.Environment
Ecosystem catalog.Ecosystem
Provider catalog.Provider
ProjectID string
// RequiredBy names the module that states the requirement, Dependency the
// module it requires, and Constraint the range that Selected fails.
RequiredBy string
Dependency string
Constraint string
Selected string
// Server marks a requirement the server itself answers. No candidate can
// change it, so it explains the failure better than any package rejection.
Server bool
Message string
}
func (p ModuleProblem) Error() string { return p.Message }
func moduleProblem(
owner *document.Package,
requiredBy string,
dependency catalog.DescriptorDependency,
selected string,
serverProvided bool,
message string,
) ModuleProblem {
return ModuleProblem{
Environment: owner.Environment,
Ecosystem: owner.Ecosystem,
Provider: owner.Provider,
ProjectID: owner.ProjectID,
RequiredBy: requiredBy,
Dependency: dependency.ID,
Constraint: dependency.Constraint,
Selected: selected,
Server: serverProvided,
Message: message,
}
}
// SearchLimitError reports that resolution stopped before it settled a graph.
// The failure it produces keeps the rejection ledger for the command layer.
type SearchLimitError struct {
Attempts int
}
func (e *SearchLimitError) Error() string {
return fmt.Sprintf(
"package resolution exceeded %d candidate combinations",
e.Attempts,
)
}
// RejectionKind names why one candidate was discarded.
type RejectionKind string
const (
RejectionVersionMismatch RejectionKind = "version-mismatch"
RejectionReleaseMismatch RejectionKind = "release-mismatch"
RejectionFilePattern RejectionKind = "file-pattern"
RejectionPrimaryFile RejectionKind = "not-primary-file"
RejectionChannel RejectionKind = "not-a-release"
RejectionModules RejectionKind = "modules"
// RejectionServer is a module requirement the server itself answers, such
// as the loader version. No candidate choice can satisfy it.
RejectionServer RejectionKind = "server"
RejectionMetadataConflict RejectionKind = "metadata-conflict"
RejectionNested RejectionKind = "nested"
RejectionEmpty RejectionKind = "empty"
)
// RejectionGroup aggregates identical rejections of one project's candidates.
type RejectionGroup struct {
Kind RejectionKind
Detail string
Count int
FirstVersion string
LastVersion string
// Module facts describe a requirement that rejected the builds. They are
// empty for kinds that are not about a module.
RequiredBy string
Dependency string
Constraint string
Selected string
}
// Failure explains why a project could not be resolved: which project, how
// many builds were considered, and why each group was rejected. The command
// layer renders it; this type only carries facts.
type Failure struct {
Project string
Provider catalog.Provider
ProjectID string
Minecraft versionpkg.Version
Ecosystem catalog.Ecosystem
Candidates int
Groups []RejectionGroup
Cause error
// Aborted marks a search that stopped before it settled a graph. Project
// and Groups describe the candidate it rejected most often; Cause names
// the stop.
Aborted bool
}
// Error reports the failure on one line. It is the fallback for surfaces that
// do not render the grouped detail.
func (f *Failure) Error() string {
if f == nil {
return ""
}
if f.Aborted {
if f.Cause != nil {
return f.Cause.Error()
}
return "package resolution stopped before it settled"
}
if len(f.Groups) > 0 && f.Groups[0].Kind == RejectionServer {
group := f.Groups[0]
return fmt.Sprintf(
"%s requires %s %s, but this server runs %s",
group.RequiredBy,
group.Dependency,
group.Constraint,
group.Selected,
)
}
var parts []string
for _, group := range f.Groups {
text := group.Detail
if group.Count > 1 {
text = fmt.Sprintf("%s (%d builds)", text, group.Count)
}
parts = append(parts, text)
}
message := fmt.Sprintf("no build of %s works on this server", f.Project)
if len(parts) > 0 {
message += ": " + strings.Join(parts, "; ")
}
return message
}
func (f *Failure) Unwrap() error { return f.Cause }
type rejectionKey struct {
kind RejectionKind
detail string
}
type rejectionGroup struct {
kind RejectionKind
detail string
count int
versions map[string]struct{}
firstVersion string
lastVersion string
requiredBy string
dependency string
constraint string
selected string
}
type projectLedger struct {
display string
provider catalog.Provider
projectID string
candidates int
groups map[rejectionKey]*rejectionGroup
order []rejectionKey
}
// rejectionLedger records why candidates were discarded, grouped by project.
// It keeps one entry per distinct reason so the failure summary can report
// "47 builds, all for the same reason" instead of the last error alone.
type rejectionLedger struct {
projects map[projectKey]*projectLedger
order []projectKey
displays map[projectKey]string
// failed holds the projects whose resolution gave up. Projects that only
// rejected builds on the way to a selection are not failures.
failed map[projectKey]bool
}
func newRejectionLedger() *rejectionLedger {
return &rejectionLedger{
projects: map[projectKey]*projectLedger{},
displays: map[projectKey]string{},
failed: map[projectKey]bool{},
}
}
func (l *rejectionLedger) reset() {
l.projects = map[projectKey]*projectLedger{}
l.order = nil
l.displays = map[projectKey]string{}
l.failed = map[projectKey]bool{}
}
func (l *rejectionLedger) markFailed(key projectKey) { l.failed[key] = true }
func (l *rejectionLedger) noteDisplay(key projectKey, display string) {
if display != "" {
l.displays[key] = display
}
}
func (l *rejectionLedger) displayFor(key projectKey) string {
return l.displays[key]
}
func (l *rejectionLedger) project(key projectKey, display string) *projectLedger {
entry, ok := l.projects[key]
if !ok {
entry = &projectLedger{
display: display,
provider: key.provider,
projectID: key.project,
groups: map[rejectionKey]*rejectionGroup{},
}
l.projects[key] = entry
l.order = append(l.order, key)
}
if display != "" {
entry.display = display
}
return entry
}
func (l *rejectionLedger) setCandidates(key projectKey, display string, count int) {
entry := l.project(key, display)
if count > entry.candidates {
entry.candidates = count
}
}
func (l *rejectionLedger) record(
key projectKey,
display string,
version, detail string,
kind RejectionKind,
) {
l.add(l.project(key, display), version, rejectionKey{kind: kind, detail: detail})
}
// recordModule records a module requirement rejection. A requirement the
// server answers becomes a server rejection, which the summary prefers over
// every package-level reason.
func (l *rejectionLedger) recordModule(
key projectKey,
display string,
version string,
problem ModuleProblem,
) {
kind := RejectionModules
if problem.Server {
kind = RejectionServer
}
group := l.add(
l.project(key, display),
version,
rejectionKey{kind: kind, detail: problem.Message},
)
group.requiredBy = problem.RequiredBy
group.dependency = problem.Dependency
group.constraint = problem.Constraint
group.selected = problem.Selected
}
func (l *rejectionLedger) add(
entry *projectLedger,
version string,
groupKey rejectionKey,
) *rejectionGroup {
group, ok := entry.groups[groupKey]
if !ok {
group = &rejectionGroup{kind: groupKey.kind, detail: groupKey.detail}
entry.groups[groupKey] = group
entry.order = append(entry.order, groupKey)
}
group.count++
if version != "" {
if group.versions == nil {
group.versions = map[string]struct{}{}
}
group.versions[version] = struct{}{}
if group.firstVersion == "" {
group.firstVersion = version
}
group.lastVersion = version
}
return group
}
// summary picks the project whose rejection explains the failure best: a
// requirement the server cannot satisfy first, then the most specific reasons,
// not the request that happened to be processed first. Nested failures only
// back up a deeper cause.
func (l *rejectionLedger) summary(minecraft versionpkg.Version, cause error) *Failure {
var bestKey projectKey
var bestEntry *projectLedger
bestServer := -1
bestScore := -1
bestTotal := -1
for _, key := range l.order {
if !l.failed[key] {
continue
}
entry := l.projects[key]
server := 0
score := 0
total := 0
for _, group := range entry.groups {
total += group.count
if group.kind == RejectionServer {
server += group.count
continue
}
if group.kind != RejectionNested {
score += group.count
}
}
if total == 0 {
continue
}
better := server > bestServer
if server == bestServer {
better = score > bestScore || (score == bestScore && total > bestTotal)
}
if better {
bestKey, bestEntry = key, entry
bestServer, bestScore, bestTotal = server, score, total
}
}
if bestEntry == nil {
return nil
}
return l.build(bestKey, bestEntry, minecraft, cause)
}
func (l *rejectionLedger) build(
key projectKey,
entry *projectLedger,
minecraft versionpkg.Version,
cause error,
) *Failure {
groups := make([]RejectionGroup, 0, len(entry.order))
for _, groupKey := range entry.order {
group := entry.groups[groupKey]
count := group.count
if len(group.versions) > 0 {
count = len(group.versions)
}
groups = append(groups, RejectionGroup{
Kind: group.kind,
Detail: group.detail,
Count: count,
FirstVersion: group.firstVersion,
LastVersion: group.lastVersion,
RequiredBy: group.requiredBy,
Dependency: group.dependency,
Constraint: group.constraint,
Selected: group.selected,
})
}
// A nested failure is the parent's summary of a deeper cause. When the
// parent also records a reason of its own, the summary adds nothing.
if len(groups) > 1 {
groups = slices.DeleteFunc(groups, func(group RejectionGroup) bool {
return group.Kind == RejectionNested
})
}
// A server constraint explains the failure better than any package
// rejection, so it leads the groups the renderers and reports read.
sort.SliceStable(groups, func(i, j int) bool {
left := groups[i].Kind == RejectionServer
right := groups[j].Kind == RejectionServer
if left != right {
return left
}
if groups[i].Count != groups[j].Count {
return groups[i].Count > groups[j].Count
}
return groups[i].Detail < groups[j].Detail
})
display := entry.display
if display == "" {
display = l.displays[key]
}
if display == "" {
display = entry.projectID
}
return &Failure{
Project: display,
Provider: entry.provider,
ProjectID: entry.projectID,
Minecraft: minecraft,
Ecosystem: key.ecosystem,
Candidates: entry.candidates,
Groups: groups,
Cause: cause,
}
}
func problemErrors(problems []ModuleProblem) []error {
errs := make([]error, 0, len(problems))
for _, problem := range problems {
errs = append(errs, problem)
}
return errs
}
// resolutionFailure prefers the structured explanation over the last error,
// which only names the request that happened to fail last.
func resolutionFailure(failure *Failure, cause error) error {
if failure != nil {
return failure
}
return cause
}
func describeRejection(kind RejectionKind, detail string) string {
switch kind {
case RejectionVersionMismatch:
return "does not match " + detail
case RejectionReleaseMismatch:
return "release " + detail + " expected"
case RejectionFilePattern:
return "filename does not match " + detail
case RejectionPrimaryFile:
return "not the primary file"
case RejectionChannel:
return "not a release build"
case RejectionMetadataConflict:
return "conflicts with " + detail
default:
return detail
}
}
package resolve
import (
serverpkg "github.com/mclucy/lucy/server"
versionpkg "github.com/mclucy/lucy/version"
)
func fmlLoader(target Target) bool {
return target.Product == serverpkg.ProductForge ||
target.Product == serverpkg.ProductNeoForge
}
func matchesLoaderCompatibility(
raw string,
module string,
target Target,
dialect versionpkg.Dialect,
) (bool, error) {
for _, compatible := range loaderCompatibilityVersions(module, target) {
matched, err := versionpkg.Match(raw, compatible, dialect)
if err != nil {
return false, err
}
if matched {
return true, nil
}
}
return false, nil
}
// loaderCompatibilityVersions mirrors the built-in version support matrices of
// Forge and NeoForge. A loader accepts a dependency when either the selected
// version or one of the module's compatibility versions satisfies the range.
// The entries and their exact Minecraft gates come from the FMLLoader classes
// shipped with the loader:
//
// - NeoForge FML 1.0.16: 1.19.2 -> javafml 42, minecraft 1.19.1, forge 42.0.9
// - NeoForge FML 4.0.x: 1.21.1 -> minecraft 1.21, neoforge 21.0.166
// - NeoForge FML 11.0.16: no entries; 11.0.23 and 12.x cover 1.21.8
// -> minecraft 1.21.7, neoforge 21.7.26-beta
// - Forge FML 43.x: 1.19.2 -> javafml 42, minecraft 1.19.1, forge 42.0.9
// - Forge FML 47.4.x: 1.20.1 -> forge 47.1.79
// - Forge FML 52.x: 1.21.1 -> javafml 51, minecraft 1.21, forge 51.0.33
// - Forge FML 54.x and 61.x keep the 1.21.1 gate, so they have no entries
// on their own Minecraft version.
//
// A compatibility entry can satisfy a dependency even when no selected package
// provides the module, because the loader's matrix substitutes a version for
// modules such as languageloader.javafml.
func loaderCompatibilityVersions(
module string,
target Target,
) []versionpkg.Version {
switch target.Product {
case serverpkg.ProductNeoForge:
switch target.Minecraft {
case "1.19.2":
switch module {
case "languageloader.javafml":
return []versionpkg.Version{"42"}
case "minecraft":
return []versionpkg.Version{"1.19.1"}
case "forge":
return []versionpkg.Version{"42.0.9"}
}
case "1.21.1":
switch module {
case "minecraft":
return []versionpkg.Version{"1.21"}
case "neoforge":
return []versionpkg.Version{"21.0.166"}
}
case "1.21.8":
switch module {
case "minecraft":
return []versionpkg.Version{"1.21.7"}
case "neoforge":
return []versionpkg.Version{"21.7.26-beta"}
}
}
case serverpkg.ProductForge:
switch target.Minecraft {
case "1.19.2":
switch module {
case "languageloader.javafml":
return []versionpkg.Version{"42"}
case "minecraft":
return []versionpkg.Version{"1.19.1"}
case "forge":
return []versionpkg.Version{"42.0.9"}
}
case "1.20.1":
if module == "forge" {
return []versionpkg.Version{"47.1.79"}
}
case "1.21.1":
switch module {
case "languageloader.javafml":
return []versionpkg.Version{"51"}
case "minecraft":
return []versionpkg.Version{"1.21"}
case "forge":
return []versionpkg.Version{"51.0.33"}
}
}
}
return nil
}
package resolve
import (
"fmt"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
serverpkg "github.com/mclucy/lucy/server"
versionpkg "github.com/mclucy/lucy/version"
)
// moduleKey scopes a module identity so identically named modules in
// different environments or ecosystems stay distinct.
type moduleKey struct {
environment serverpkg.Environment
ecosystem catalog.Ecosystem
id string
}
// moduleSource names the artifact a module came from. The offsets identify the
// declaration inside its package; runtime modules have no package.
type moduleSource struct {
packageIndex int
moduleIndex int
}
func (s moduleSource) owns(packageIndex, moduleIndex int) bool {
return s.packageIndex == packageIndex && s.moduleIndex == moduleIndex
}
func (s moduleSource) runtime() bool {
return s.packageIndex < 0
}
// moduleProvider is the module that answers one identity, together with the
// coordinates a dependency binds to.
type moduleProvider struct {
source moduleSource
module catalog.DescriptorModule
provider catalog.Provider
projectID string
}
// finalizeModules checks module requirements against the target and drops
// modules that lose to another selected module with the same identity. It
// returns the problems it found; only an unparsable or ambiguous module
// identity is an error.
func finalizeModules(packages []document.Package, target Target) ([]ModuleProblem, error) {
runtime := runtimeModules(target)
index, err := buildModuleIndex(packages, target, runtime)
if err != nil {
return nil, err
}
var problems []ModuleProblem
surviving := make([][]catalog.DescriptorModule, len(packages))
for pi := range packages {
p := &packages[pi]
if !p.Enabled() {
continue
}
for mi := range p.Modules {
m := p.Modules[mi]
key := moduleKey{
p.Environment,
p.Ecosystem,
normalizeModuleID(m.Ecosystem, m.ID),
}
if owner, ok := index[key]; !ok || !owner.source.owns(pi, mi) {
continue
}
problems = append(
problems,
checkModuleDependencies(p, m, target, index, runtime)...,
)
surviving[pi] = append(surviving[pi], m)
}
}
// Assignment happens only after every package has been checked, so a
// lookup never observes a compacted module list.
for pi := range packages {
if !packages[pi].Enabled() {
continue
}
packages[pi].Modules = surviving[pi]
}
return problems, nil
}
// buildModuleIndex resolves every module identity to the declaration that
// answers it. A native module outranks a bundled copy of the same identity.
// Bundled copies from different packages compare versions; bundled copies from
// the same package are loader-selected variants, so the one whose requirements
// the server satisfies wins first. Two native modules in different packages are
// ambiguous.
func buildModuleIndex(
packages []document.Package,
target Target,
runtime map[string]catalog.DescriptorModule,
) (map[moduleKey]moduleProvider, error) {
index := map[moduleKey]moduleProvider{}
for pi := range packages {
p := &packages[pi]
if !p.Enabled() {
continue
}
for mi, m := range p.Modules {
candidate := moduleProvider{
source: moduleSource{packageIndex: pi, moduleIndex: mi},
module: m,
provider: p.Provider,
projectID: p.ProjectID,
}
names := append([]string{m.ID}, m.Provides...)
for _, name := range names {
key := moduleKey{
p.Environment,
p.Ecosystem,
normalizeModuleID(m.Ecosystem, name),
}
previous, ok := index[key]
if !ok {
index[key] = candidate
continue
}
keep, err := moduleWins(
previous,
candidate,
packages[previous.source.packageIndex].Filename,
p.Filename,
name,
target,
runtime,
)
if err != nil {
return nil, err
}
if !keep {
index[key] = candidate
}
}
}
}
return index, nil
}
// moduleWins reports whether the module already indexed keeps the identity.
func moduleWins(
previous moduleProvider,
candidate moduleProvider,
previousFile string,
candidateFile string,
name string,
target Target,
runtime map[string]catalog.DescriptorModule,
) (bool, error) {
previousNative := previous.module.NestedPath == ""
candidateNative := candidate.module.NestedPath == ""
if previousNative && candidateNative {
if previous.source.packageIndex == candidate.source.packageIndex {
return true, nil
}
return false, fmt.Errorf(
"duplicate native module %s in %s and %s",
name,
candidateFile,
previousFile,
)
}
if previousNative {
return true, nil
}
if candidateNative {
return false, nil
}
if previous.source.packageIndex == candidate.source.packageIndex {
previousServes, previousKnown := servesTarget(previous.module, target, runtime)
candidateServes, candidateKnown := servesTarget(candidate.module, target, runtime)
if previousKnown && candidateKnown && previousServes != candidateServes {
return previousServes, nil
}
}
a, err := versionpkg.ParseVersion(
previous.module.Version,
previous.module.VersionDialect,
)
if err != nil {
return false, err
}
b, err := versionpkg.ParseVersion(
candidate.module.Version,
candidate.module.VersionDialect,
)
if err != nil {
return false, err
}
comparison, comparable := versionpkg.Compare(a, b)
if !comparable {
return false, fmt.Errorf(
"incomparable nested module versions %s and %s",
previous.module.Version,
candidate.module.Version,
)
}
return comparison >= 0, nil
}
// servesTarget reports whether the requirements a module places on the modules
// the server itself provides hold for target. A wrapper that bundles one nested
// jar per Minecraft line publishes every variant under one identity with
// disjoint requirements, and the loader loads the variant the running version
// satisfies. Requirements that another package answers are not part of that
// decision, because they do not vary with the server.
//
// The second result is false when a requirement cannot be evaluated, so the
// caller keeps its version order instead of guessing.
func servesTarget(
m catalog.DescriptorModule,
target Target,
runtime map[string]catalog.DescriptorModule,
) (bool, bool) {
for di := range m.Dependencies {
dep := &m.Dependencies[di]
if dep.Kind != catalog.DependencyKindRequired || dep.ID == "java" {
continue
}
provided, ok := runtime[normalizeModuleID(m.Ecosystem, dep.ID)]
if !ok || dep.Constraint == "" {
continue
}
matched := fmlLoader(target) && provided.Version == "0.0NONE"
if !matched {
var err error
matched, err = versionpkg.Match(dep.Constraint, provided.Version, m.VersionDialect)
if err != nil {
return false, false
}
}
if !matched {
var err error
matched, err = matchesLoaderCompatibility(dep.Constraint, dep.ID, target, m.VersionDialect)
if err != nil {
return false, false
}
}
if !matched {
return false, true
}
}
return true, true
}
// runtimeModules reports the modules the runtime itself provides. They answer
// requirements before any package does, the same way a loader resolves its own
// modules before the package graph.
func runtimeModules(target Target) map[string]catalog.DescriptorModule {
modules := map[string]catalog.DescriptorModule{}
add := func(module catalog.DescriptorModule) {
id := normalizeModuleID(module.Ecosystem, module.ID)
if id == "" {
return
}
if _, ok := modules[id]; ok {
return
}
modules[id] = module
}
add(catalog.DescriptorModule{
ID: "minecraft",
Version: target.Minecraft,
Ecosystem: catalog.EcosystemMinecraft,
})
switch target.Product {
case serverpkg.ProductFabric:
add(catalog.DescriptorModule{
ID: "fabricloader",
Version: target.CoreVersion,
Ecosystem: catalog.EcosystemFabric,
})
case serverpkg.ProductForge:
add(catalog.DescriptorModule{
ID: "forge",
Version: target.CoreVersion,
Ecosystem: catalog.EcosystemForge,
})
case serverpkg.ProductNeoForge:
add(catalog.DescriptorModule{
ID: "neoforge",
Version: target.CoreVersion,
Ecosystem: catalog.EcosystemNeoForge,
})
}
if target.MCDRVersion != "" {
add(catalog.DescriptorModule{
ID: "mcdreforged",
Version: versionpkg.Version(target.MCDRVersion),
Ecosystem: catalog.EcosystemMCDR,
})
}
for _, module := range target.CoreModules {
add(module)
}
return modules
}
func normalizeModuleID(ecosystem catalog.Ecosystem, id string) string {
if ecosystem == catalog.EcosystemBukkit || ecosystem == catalog.EcosystemPaper {
return strings.ToLower(id)
}
return id
}
func checkModuleDependencies(
p *document.Package,
m catalog.DescriptorModule,
target Target,
index map[moduleKey]moduleProvider,
runtime map[string]catalog.DescriptorModule,
) []ModuleProblem {
var problems []ModuleProblem
for di := range m.Dependencies {
dep := &m.Dependencies[di]
// A Java requirement is checked against the Java runtime that runs
// the server, and only warns.
if dep.Kind == catalog.DependencyKindPython || dep.ID == "java" {
continue
}
value := ""
var owner *moduleProvider
if module, ok := runtime[normalizeModuleID(p.Ecosystem, dep.ID)]; ok {
value = string(module.Version)
} else {
key := moduleKey{
p.Environment,
p.Ecosystem,
normalizeModuleID(p.Ecosystem, dep.ID),
}
if provider, ok := index[key]; ok {
owner = &provider
value = string(provider.module.Version)
}
}
// serverProvided marks a requirement the target satisfies from its own
// modules. No candidate can change such a value.
serverProvided := owner == nil && value != ""
matched := value != ""
invalid := false
if fmlLoader(target) && value == "0.0NONE" {
// FMLLoader treats the 0.0NONE sentinel as satisfying every range.
matched = true
} else if matched && dep.Constraint != "" {
var err error
matched, err = versionpkg.Match(
dep.Constraint,
versionpkg.Version(value),
m.VersionDialect,
)
if err != nil {
problems = append(problems, moduleProblem(p, m.ID, *dep, value, serverProvided, fmt.Sprintf("module %s requires %s %s: %v", m.ID, dep.ID, dep.Constraint, err)))
invalid = true
}
}
if !invalid && dep.Constraint != "" && !matched {
compatible, err := matchesLoaderCompatibility(
dep.Constraint,
dep.ID,
target,
m.VersionDialect,
)
if err != nil {
problems = append(problems, moduleProblem(p, m.ID, *dep, value, serverProvided, fmt.Sprintf("module %s requires %s %s: %v", m.ID, dep.ID, dep.Constraint, err)))
invalid = true
} else {
matched = compatible
}
}
requirement := strings.TrimSpace(dep.ID + " " + dep.Constraint)
if !invalid && dep.Kind == catalog.DependencyKindRequired && !matched {
if value == "" {
problems = append(problems, moduleProblem(p, m.ID, *dep, "", false, fmt.Sprintf("module %s requires %s, which no selected package provides", m.ID, requirement)))
} else {
problems = append(problems, moduleProblem(p, m.ID, *dep, value, serverProvided, fmt.Sprintf("module %s requires %s (selected %s)", m.ID, requirement, value)))
}
}
if !invalid && dep.Kind == catalog.DependencyKindIncompatible && matched {
problems = append(problems, moduleProblem(p, m.ID, *dep, value, serverProvided, fmt.Sprintf("module %s breaks %s %s", m.ID, dep.ID, value)))
}
if matched {
if owner != nil && !owner.source.runtime() {
dep.Provider = owner.provider
dep.ProjectID = owner.projectID
dep.ModuleID = owner.module.ID
} else {
dep.ModuleID = dep.ID
}
}
}
return problems
}
package resolve
import (
"cmp"
"context"
"errors"
"fmt"
"slices"
"sort"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/artifact"
serverpkg "github.com/mclucy/lucy/server"
versionpkg "github.com/mclucy/lucy/version"
)
// searchAttemptLimit bounds how many candidate combinations one resolution
// may explore before it reports the stop together with the rejection ledger.
const searchAttemptLimit = 10000
// catalogueRegistry names the registry that serves a package provider. Only a
// provider backing a registry can answer catalogue queries.
func catalogueRegistry(provider catalog.Provider) catalog.Registry {
registry, ok := provider.Registry()
if !ok {
return catalog.RegistryUnspecified
}
return registry
}
// selectionState owns the branch-local graph of the package search: the
// chosen packages and the incompatibilities their metadata declares. An
// artifact and its declarations form one selection, so the maps change
// together. Canonical identities, candidate lists, and artifact analysis are
// session-scoped caches and deliberately live outside this state.
type selectionState struct {
chosen map[projectKey]document.Package
conflicts map[projectKey][]upstream.ProjectDependency
}
type selectionBranch struct {
key projectKey
previous document.Package
hadPrevious bool
conflicts []upstream.ProjectDependency
hadConflicts bool
}
func newSelectionState() selectionState {
return selectionState{
chosen: map[projectKey]document.Package{},
conflicts: map[projectKey][]upstream.ProjectDependency{},
}
}
func (s *selectionState) reset() {
clear(s.chosen)
clear(s.conflicts)
}
func (s *selectionState) selection(key projectKey) (document.Package, bool) {
p, ok := s.chosen[key]
return p, ok
}
func (s *selectionState) extend(key projectKey, p document.Package) selectionBranch {
return s.install(key, p, s.conflicts[key])
}
func (s *selectionState) install(
key projectKey,
p document.Package,
declared []upstream.ProjectDependency,
) selectionBranch {
previous, hadPrevious := s.chosen[key]
conflicts, hadConflicts := s.conflicts[key]
s.chosen[key] = p
if len(declared) > 0 {
s.conflicts[key] = declared
} else {
delete(s.conflicts, key)
}
return selectionBranch{
key: key,
previous: previous,
hadPrevious: hadPrevious,
conflicts: conflicts,
hadConflicts: hadConflicts,
}
}
func (s *selectionState) restore(branch selectionBranch) {
if branch.hadPrevious {
s.chosen[branch.key] = branch.previous
} else {
delete(s.chosen, branch.key)
}
if branch.hadConflicts {
s.conflicts[branch.key] = branch.conflicts
} else {
delete(s.conflicts, branch.key)
}
}
func Packages(request PackagesRequest) ([]document.Package, []error, error) {
ctx := request.Context
logger := request.Logger
candidatesFor := request.Candidates
if ctx == nil {
ctx = context.Background()
}
d := request.Manifest
previous := request.Previous
target := request.Target
materialize := request.Materialize
withOptional := request.WithOptional
acceptBroken := request.AcceptBroken
expandDependencies := request.ExpandDependencies
trace := request.Trace
var requests []projectRequest
appendRequest := func(environment serverpkg.Environment, ecosystem catalog.Ecosystem, ref string, r document.Requirement) error {
provider, project, err := document.ParseReference(ref)
if err != nil {
return err
}
// The slug the user wrote is the display name even when the lock lets
// resolution start from the project identity.
display := project
for _, p := range previous {
if p.Environment == environment && p.Ecosystem == ecosystem && p.Provider == provider {
for _, old := range p.Requirements {
if old.Reference == ref {
project = p.ProjectID
}
}
}
}
requests = append(requests, projectRequest{
key: projectKey{
environment,
ecosystem,
provider,
project,
},
display: display,
origin: originManifest,
requirements: []document.RequirementBinding{
{
Reference: ref,
Selector: r.Selector,
FilePattern: r.FilePattern,
Enabled: r.IsEnabled(),
},
},
})
return nil
}
var ecosystems []catalog.Ecosystem
for ecosystem := range d.Server.Packages {
ecosystems = append(ecosystems, ecosystem)
}
slices.Sort(ecosystems)
for _, ecosystem := range ecosystems {
var refs []string
for ref := range d.Server.Packages[ecosystem] {
refs = append(refs, ref)
}
slices.Sort(refs)
for _, ref := range refs {
if err := appendRequest(serverpkg.EnvironmentServer, ecosystem, ref, d.Server.Packages[ecosystem][ref]); err != nil {
return nil, nil, err
}
}
}
if d.MCDR != nil {
var refs []string
for ref := range d.MCDR.Packages {
refs = append(refs, ref)
}
slices.Sort(refs)
for _, ref := range refs {
if err := appendRequest(serverpkg.EnvironmentMCDR, catalog.EcosystemMCDR, ref, d.MCDR.Packages[ref]); err != nil {
return nil, nil, err
}
}
}
for _, r := range requests {
if r.key.environment == serverpkg.EnvironmentServer {
if !SupportsPackages(target, r.key.ecosystem) {
for _, req := range r.requirements {
if req.Enabled {
return nil, nil, fmt.Errorf("product %s cannot load %s packages", target.Product, r.key.ecosystem)
}
}
}
}
}
candidates := map[projectKey][]upstream.Candidate{}
canonical := map[projectKey]projectKey{}
state := newSelectionState()
cache := request.Cache
attempts := 0
strict := true
var lastErr error
ledger := newRejectionLedger()
logger.Info(
"resolve: resolving package requirements",
"requests", len(requests),
)
// A hard failure is an error that no candidate rejection explains, such as
// a network, checksum, or archive error. It outranks the rejection summary.
operational := false
hardFail := func(err error) bool {
lastErr = err
operational = true
return false
}
var backtrack func([]projectRequest) bool
backtrack = func(pending []projectRequest) bool {
if err := ctx.Err(); err != nil {
return hardFail(err)
}
attempts++
if attempts > searchAttemptLimit {
return hardFail(&SearchLimitError{Attempts: searchAttemptLimit})
}
trace.state(len(state.chosen), len(pending))
if len(pending) == 0 {
all := make([]document.Package, 0, len(state.chosen))
for _, p := range state.chosen {
all = append(all, p)
}
all = mergeArtifactSelections(all)
sortPackages(all)
problems, err := finalizeModules(all, target)
if err != nil {
logger.Debug(
"resolve: settled graph invalid",
"attempt", attempts,
"reason", err,
)
return hardFail(err)
}
if len(problems) > 0 && strict {
logger.Debug(
"resolve: settled graph rejected",
"attempt", attempts,
"reason", problems[0],
)
lastErr = errors.Join(problemErrors(problems)...)
for _, problem := range problems {
key := projectKey{
problem.Environment,
problem.Ecosystem,
problem.Provider,
problem.ProjectID,
}
display := ledger.displayFor(key)
if display == "" {
display = problem.ProjectID
}
version := ""
if owner, ok := state.selection(key); ok {
version = string(owner.Version)
}
ledger.recordModule(
key,
display,
version,
problem,
)
ledger.markFailed(key)
}
return false
}
return true
}
r := pending[0]
rest := pending[1:]
if canonicalKey, ok := canonical[r.key]; ok {
r.key = canonicalKey
}
ledger.noteDisplay(r.key, r.display)
dependencyDemand := r.dependencyOf.Name != ""
active := len(r.requirements) == 0
for _, req := range r.requirements {
active = active || req.Enabled
}
// An artifact-less entry records only a disabled direct binding. When
// something else in the graph needs the package, that demand outranks
// the binding, so the artifact resolves instead of merging into a
// package that no file can satisfy.
if previous, ok := state.selection(r.key); ok &&
(!active || previous.Artifact.Filename != "") {
previous.Requirements = append(previous.Requirements, r.requirements...)
previous.Dependency = previous.Dependency || dependencyDemand
if dependencyDemand && !slices.Contains(previous.DependencyOf, r.dependencyOf) {
previous.DependencyOf = append(previous.DependencyOf, r.dependencyOf)
}
branch := state.extend(r.key, previous)
if backtrack(rest) {
return true
}
state.restore(branch)
logger.Debug(
"resolve: backtracking requirement merge",
"provider", r.key.provider,
"project", r.key.project,
"reason", "remaining requests failed after merge",
)
return false
}
if !active {
p := document.Package{
Environment: r.key.environment,
Ecosystem: r.key.ecosystem,
Provider: r.key.provider,
ProjectID: r.key.project,
Requirements: r.requirements,
}
branch := state.install(r.key, p, nil)
if backtrack(rest) {
return true
}
state.restore(branch)
return false
}
// A disabled direct binding may already hold an artifact-less entry
// here, because a real demand outranks it. Selecting a file replaces
// the entry, so the binding's requirements have to travel into the
// selection and back out again when the selection does not resolve.
var artifactless document.Package
replacingArtifactless := false
if prior, ok := state.selection(r.key); ok && prior.Artifact.Filename == "" {
artifactless, replacingArtifactless = prior, true
}
list, ok := candidates[r.key]
if !ok {
if candidatesFor == nil {
return hardFail(errors.New("resolution requires a candidate source"))
}
var err error
list, err = candidatesFor(ctx, catalogueRegistry(r.key.provider), r.key.project, string(target.Minecraft), r.key.ecosystem)
if err != nil {
return hardFail(err)
}
list = orderCandidates(list, previous, r)
candidates[r.key] = list
ledger.setCandidates(r.key, r.display, len(list))
}
if len(list) > 0 {
// Direct requirements name a project by its catalogue slug, while
// catalogue metadata names the same project by identity. Resolve
// under the identity so the project enters the graph only once.
if project := list[0].ProjectID; project != "" && project != r.key.project {
canonicalKey := r.key
canonicalKey.project = project
canonical[r.key] = canonicalKey
if _, cached := candidates[canonicalKey]; !cached {
candidates[canonicalKey] = list
}
ledger.setCandidates(canonicalKey, r.display, len(list))
logger.Debug(
"resolve: project identity canonicalized",
"provider", r.key.provider,
"reference", r.key.project,
"project", project,
)
return backtrack(append([]projectRequest{{
key: canonicalKey,
display: r.display,
origin: r.origin,
requirements: r.requirements,
dependencyOf: r.dependencyOf,
releaseHints: r.releaseHints,
}}, rest...))
}
}
if len(list) == 0 {
ledger.record(r.key, r.display, "", "no published builds match this server", RejectionEmpty)
}
list = preferReleaseBuilds(list, r.requirements)
list = preferReleaseHints(list, r.releaseHints)
for _, candidate := range list {
decision, err := decideCandidate(candidate, r)
if err != nil {
return hardFail(err)
}
if !decision.eligible {
ledger.record(r.key, r.display, string(candidate.Version), decision.detail, decision.kind)
trace.rejected(r.display, string(candidate.Version), decision.reason)
logger.Debug(
"resolve: candidate rejected",
"provider", r.key.provider,
"project", r.key.project,
"origin", r.origin.String(),
"candidate", candidate.Version,
"file", candidate.Filename,
"reason", decision.reason,
)
continue
}
if detail, conflicting := metadataConflict(candidate, r, state.chosen, state.conflicts); conflicting {
reason := describeRejection(RejectionMetadataConflict, detail)
ledger.record(r.key, r.display, string(candidate.Version), detail, RejectionMetadataConflict)
trace.rejected(r.display, string(candidate.Version), reason)
logger.Debug(
"resolve: candidate rejected",
"provider", r.key.provider,
"project", r.key.project,
"origin", r.origin.String(),
"candidate", candidate.Version,
"file", candidate.Filename,
"reason", reason,
)
continue
}
a := candidate.Artifact
var modules []catalog.DescriptorModule
entry, cached := analysisEntry{}, false
if cache != nil {
entry, cached = cache.get(candidateAnalysisKey(candidate))
}
if cached {
a.Hashes = entry.hashes
modules = entry.modules
} else {
materialized, err := materialize(&a)
if err != nil {
return hardFail(err)
}
a.Hashes = materialized.Hashes
modules, err = artifact.ReadModules(ctx, materialized.Path, r.key.ecosystem)
if err != nil {
return hardFail(err)
}
if cache != nil && len(modules) > 0 {
cache.put(candidateAnalysisKey(candidate), analysisEntry{
modules: modules,
hashes: a.Hashes,
})
}
}
if len(modules) == 0 {
message := fmt.Sprintf("artifact %s has no server-visible %s modules", a.Filename, r.key.ecosystem)
lastErr = errors.New(message)
ledger.record(r.key, r.display, string(candidate.Version), message, RejectionModules)
trace.rejected(r.display, string(candidate.Version), message)
continue
}
a.Modules = modules
requirements := r.requirements
if replacingArtifactless {
requirements = append(slices.Clone(artifactless.Requirements), r.requirements...)
}
var dependencyOf []catalog.Ref
if dependencyDemand {
dependencyOf = []catalog.Ref{r.dependencyOf}
}
branch := state.install(r.key, document.Package{
Environment: r.key.environment,
Ecosystem: r.key.ecosystem,
Artifact: a,
Requirements: requirements,
Dependency: dependencyDemand,
DependencyOf: dependencyOf,
}, conflictDependencies(candidate))
logger.Debug(
"resolve: candidate selected",
"provider", r.key.provider,
"project", r.key.project,
"candidate", candidate.Version,
"file", candidate.Filename,
"modules", len(modules),
)
trace.selected(r.display, string(candidate.Version))
next := append([]projectRequest{}, rest...)
if expandDependencies {
for _, dep := range candidate.Dependencies {
if dep.Kind == catalog.DependencyKindRequired || (withOptional && dep.Kind == catalog.DependencyKindOptional) {
var hints []releaseHint
if dep.ReleaseID != "" {
hints = append(hints, releaseHint{releaseID: dep.ReleaseID})
}
dependency := projectRequest{
key: projectKey{
r.key.environment,
r.key.ecosystem,
dep.Provider,
dep.ProjectID,
},
origin: originDependency,
releaseHints: hints,
}
if dep.Kind == catalog.DependencyKindRequired {
dependency.dependencyOf = catalog.Ref{
Provider: r.key.provider,
Name: catalog.Name(r.key.project),
}
}
if dep.Kind == catalog.DependencyKindOptional {
dependency.requirements = []document.RequirementBinding{
{
Reference: dep.Provider.String() + ":" + dep.ProjectID,
Selector: versionpkg.Stable,
Enabled: true,
},
}
}
next = append(next, dependency)
}
}
}
if backtrack(next) {
return true
}
state.restore(branch)
logger.Debug(
"resolve: backtracking candidate",
"provider", r.key.provider,
"project", r.key.project,
"candidate", candidate.Version,
"reason", "nested resolution failed",
)
ledger.record(
r.key,
r.display,
string(candidate.Version),
"a required dependency could not be resolved",
RejectionNested,
)
}
if lastErr == nil {
lastErr = fmt.Errorf("no candidate satisfies %s:%s", r.key.provider, r.key.project)
}
ledger.markFailed(r.key)
return false
}
search := func(allowBroken bool) bool {
state.reset()
attempts = 0
strict = !allowBroken
lastErr = nil
operational = false
ledger.reset()
return backtrack(requests)
}
finalFailure := func() error {
if err := ctx.Err(); err != nil {
return err
}
if operational {
var limit *SearchLimitError
if errors.As(lastErr, &limit) {
// The search stopped early, so keep the rejection ledger the
// command layer renders instead of the bare stop message.
summary := ledger.summary(target.Minecraft, limit)
if summary == nil {
summary = &Failure{Minecraft: target.Minecraft}
}
summary.Cause = limit
summary.Aborted = true
return summary
}
return lastErr
}
return resolutionFailure(ledger.summary(target.Minecraft, lastErr), lastErr)
}
if !search(false) {
if !acceptBroken {
return nil, nil, finalFailure()
}
logger.Debug(
"resolve: retrying with broken graphs",
"reason", lastErr,
)
if !search(true) {
return nil, nil, finalFailure()
}
}
var out []document.Package
for _, p := range state.chosen {
// A requirement the manifest disables and nothing demands pins no
// artifact, so recording it would leave the lock claiming a package
// that has no file to install.
if p.Artifact.Filename == "" {
continue
}
out = append(out, p)
}
out = mergeArtifactSelections(out)
sortPackages(out)
for i := range out {
slices.SortFunc(out[i].DependencyOf, func(a, b catalog.Ref) int {
return cmp.Or(cmp.Compare(a.Provider, b.Provider), cmp.Compare(a.Name, b.Name))
})
}
problems, err := finalizeModules(out, target)
if err != nil {
return nil, nil, err
}
var warnings []error
if len(problems) > 0 {
if !acceptBroken {
return nil, nil, errors.Join(problemErrors(problems)...)
}
warnings = problemErrors(problems)
}
logger.Info(
"resolve: selected packages",
"packages", len(out),
)
return out, warnings, nil
}
func mergeArtifactSelections(packages []document.Package) []document.Package {
type key struct {
environment serverpkg.Environment
ecosystem catalog.Ecosystem
provider catalog.Provider
project, release, file string
}
index := map[key]int{}
out := make([]document.Package, 0, len(packages))
for _, p := range packages {
k := key{
p.Environment,
p.Ecosystem,
p.Provider,
p.ProjectID,
p.ReleaseID,
p.FileID,
}
if i, ok := index[k]; ok {
out[i].Requirements = append(out[i].Requirements, p.Requirements...)
out[i].Dependency = out[i].Dependency || p.Dependency
for _, source := range p.DependencyOf {
if !slices.Contains(out[i].DependencyOf, source) {
out[i].DependencyOf = append(out[i].DependencyOf, source)
}
}
continue
}
index[k] = len(out)
out = append(out, p)
}
return out
}
// sortPackages orders packages by their persisted identity so validation
// reports findings in a stable order.
func sortPackages(packages []document.Package) {
sort.Slice(packages, func(i, j int) bool {
a, b := &packages[i], &packages[j]
if n := strings.Compare(string(a.Environment), string(b.Environment)); n != 0 {
return n < 0
}
if n := strings.Compare(string(a.Ecosystem), string(b.Ecosystem)); n != 0 {
return n < 0
}
if n := strings.Compare(string(a.Provider), string(b.Provider)); n != 0 {
return n < 0
}
return strings.Compare(a.ProjectID, b.ProjectID) < 0
})
}
package resolve
import (
"context"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/log"
serverpkg "github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
type (
requestOrigin uint8
releaseHint struct {
releaseID string
}
projectKey struct {
environment serverpkg.Environment
ecosystem catalog.Ecosystem
provider catalog.Provider
project string
}
projectRequest struct {
key projectKey
display string
origin requestOrigin
requirements []document.RequirementBinding
dependencyOf catalog.Ref
// releaseHints come from remote dependency metadata. They order
// candidates but do not by themselves reject another build.
releaseHints []releaseHint
}
)
const (
originManifest requestOrigin = iota
originDependency
)
func (o requestOrigin) String() string {
switch o {
case originManifest:
return "manifest"
case originDependency:
return "dependency"
default:
return "unknown"
}
}
// Target identifies the server runtime whose compatibility rules package
// resolution enforces.
type Target struct {
Product serverpkg.Product
Bootstrapper string
Minecraft versionpkg.Version
CoreVersion versionpkg.Version
MCDRVersion string
// CoreModules are the descriptor modules the server core artifact ships.
// Fabric's loader bundles mods as nested jars, so these satisfy package
// requirements that no package provides.
CoreModules []catalog.DescriptorModule
}
// SupportsPackages reports whether the target product loads packages from
// ecosystem. A paperclip server loads Paper and Bukkit packages regardless of
// how the manifest names its product.
func SupportsPackages(target Target, ecosystem catalog.Ecosystem) bool {
switch target.Product {
case serverpkg.ProductFabric:
return ecosystem == catalog.EcosystemFabric
case serverpkg.ProductForge:
return ecosystem == catalog.EcosystemForge
case serverpkg.ProductNeoForge:
return ecosystem == catalog.EcosystemNeoForge
case serverpkg.ProductPaper, serverpkg.ProductPurpur:
return ecosystem == catalog.EcosystemPaper || ecosystem == catalog.EcosystemBukkit
default:
return target.Bootstrapper == "paperclip" &&
(ecosystem == catalog.EcosystemPaper || ecosystem == catalog.EcosystemBukkit)
}
}
// CandidateSource lists the published builds of one project. Callers inject
// the routing capability that serves their configured providers; resolution
// never selects a provider on its own.
type CandidateSource func(
ctx context.Context,
registry catalog.Registry,
project, minecraft string,
ecosystem catalog.Ecosystem,
) ([]upstream.Candidate, error)
// PackagesRequest describes one package resolution. Trace, Logger, and Context
// default to their zero values, which report nothing and never cancel. A
// resolution that needs candidates fails when Candidates is nil.
type PackagesRequest struct {
Context context.Context
Manifest *document.Manifest
Previous []document.Package
Target Target
// Candidates lists the builds a direct or dependency requirement may
// select. It is required for every non-empty resolution.
Candidates CandidateSource
// Materialize must leave its input unchanged and return verified local bytes.
Materialize func(*catalog.Artifact) (artifact.MaterializedFile, error)
WithOptional bool
AcceptBroken bool
ExpandDependencies bool
// Cache reuses artifact analysis across candidate combinations. A nil
// cache analyzes every candidate from scratch.
Cache *AnalysisCache
// Logger records resolution diagnostics. A nil Logger records nothing.
Logger *log.Logger
Trace Trace
}
package resolve
// Trace receives facts about one resolution as they happen. Every field is
// optional: a nil field or a nil Trace reports nothing.
//
// Hooks run on the goroutine that searches, one call at a time. A caller that
// resolves concurrently must synchronize its own sink.
type Trace struct {
// State reports how many projects are chosen and how many requests remain.
State func(resolved, pending int)
// Selected reports the candidate that entered the graph.
Selected func(project, version string)
// Rejected reports a candidate that did not enter the graph. The reason is
// the same text the failure summary groups, without the package's name.
Rejected func(project, version, reason string)
}
func (t Trace) state(resolved, pending int) {
if t.State != nil {
t.State(resolved, pending)
}
}
func (t Trace) selected(project, version string) {
if t.Selected != nil {
t.Selected(project, version)
}
}
func (t Trace) rejected(project, version, reason string) {
if t.Rejected != nil {
t.Rejected(project, version, reason)
}
}
package server
import (
"strings"
"github.com/mclucy/lucy/catalog"
)
// Product identifies the executable server product. It is the ground
// fact about what the server runs; an Ecosystem describes what it can serve.
type Product string
const (
ProductUnknown Product = ""
ProductMinecraft Product = "minecraft"
ProductFabric Product = "fabric"
ProductForge Product = "forge"
ProductNeoForge Product = "neoforge"
ProductMCDReforged Product = "mcdreforged"
ProductCraftBukkit Product = "craftbukkit"
ProductSpigot Product = "spigot"
ProductPaper Product = "paper"
ProductPurpur Product = "purpur"
ProductFolia Product = "folia"
ProductLeaves Product = "leaves"
ProductArclight Product = "arclight"
ProductArclightForge Product = "arclight-forge"
ProductArclightNeoForge Product = "arclight-neoforge"
ProductArclightFabric Product = "arclight-fabric"
ProductCatServer Product = "catserver"
ProductYouer Product = "youer"
ProductSpongeVanilla Product = "spongevanilla"
ProductSpongeForge Product = "spongeforge"
ProductSpongeNeo Product = "spongeneo"
ProductBungeeCord Product = "bungeecord"
ProductVelocity Product = "velocity"
ProductWaterfall Product = "waterfall"
)
func (p Product) String() string {
return string(p)
}
func (p Product) Title() string {
switch p {
case ProductMinecraft:
return "Minecraft"
case ProductFabric:
return "Fabric"
case ProductForge:
return "Forge"
case ProductNeoForge:
return "NeoForge"
case ProductMCDReforged:
return "MCDReforged"
case ProductCraftBukkit:
return "CraftBukkit"
case ProductSpigot:
return "Spigot"
case ProductPaper:
return "Paper"
case ProductPurpur:
return "Purpur"
case ProductFolia:
return "Folia"
case ProductLeaves:
return "Leaves"
case ProductArclight:
return "Arclight"
case ProductArclightForge:
return "Arclight Forge"
case ProductArclightNeoForge:
return "Arclight NeoForge"
case ProductArclightFabric:
return "Arclight Fabric"
case ProductCatServer:
return "CatServer"
case ProductYouer:
return "Youer"
case ProductSpongeVanilla:
return "SpongeVanilla"
case ProductSpongeForge:
return "SpongeForge"
case ProductSpongeNeo:
return "SpongeNeo"
case ProductBungeeCord:
return "BungeeCord"
case ProductVelocity:
return "Velocity"
case ProductWaterfall:
return "Waterfall"
default:
return "Unknown"
}
}
type Match struct {
Product Product
Ecosystem catalog.Ecosystem
}
type productAlias struct {
Name catalog.Name
Ecosystem catalog.Ecosystem
}
type productDefinition struct {
Product Product
Ecosystem catalog.Ecosystem
Aliases []productAlias
}
// productDefinitions maps accepted product spellings to canonical
// server products. Ecosystem disambiguates aliases such as Sponge's Forge
// variant.
var productDefinitions = []productDefinition{
{
Product: ProductMinecraft,
Ecosystem: catalog.EcosystemMinecraft,
Aliases: []productAlias{
{"minecraft", catalog.EcosystemUnspecified},
{"mc", catalog.EcosystemUnspecified},
{"vanilla", catalog.EcosystemUnspecified},
{"minecraft", catalog.EcosystemMinecraft},
{"mc", catalog.EcosystemMinecraft},
{"vanilla", catalog.EcosystemMinecraft},
},
},
{
Product: ProductFabric,
Ecosystem: catalog.EcosystemFabric,
Aliases: []productAlias{
{"fabric", catalog.EcosystemUnspecified},
{"fabric-loader", catalog.EcosystemUnspecified},
{"fabric", catalog.EcosystemFabric},
{"fabric-loader", catalog.EcosystemFabric},
},
},
{
Product: ProductForge,
Ecosystem: catalog.EcosystemForge,
Aliases: []productAlias{
{"forge", catalog.EcosystemUnspecified},
{"forge", catalog.EcosystemForge},
},
},
{
Product: ProductNeoForge,
Ecosystem: catalog.EcosystemNeoForge,
Aliases: []productAlias{
{"neoforge", catalog.EcosystemUnspecified},
{"neoforge", catalog.EcosystemNeoForge},
},
},
{
Product: ProductMCDReforged,
Ecosystem: catalog.EcosystemMCDR,
Aliases: []productAlias{
{"mcdreforged", catalog.EcosystemUnspecified},
{"mcdr", catalog.EcosystemUnspecified},
{"mcdreforged", catalog.EcosystemMCDR},
{"mcdr", catalog.EcosystemMCDR},
},
},
{
Product: ProductCraftBukkit,
Ecosystem: catalog.EcosystemBukkit,
Aliases: []productAlias{
{"bukkit", catalog.EcosystemUnspecified},
{"craftbukkit", catalog.EcosystemUnspecified},
{"bukkit", catalog.EcosystemBukkit},
{"craftbukkit", catalog.EcosystemBukkit},
},
},
{
Product: ProductSpigot,
Ecosystem: catalog.EcosystemBukkit,
Aliases: []productAlias{
{"spigot", catalog.EcosystemUnspecified},
{"spigot", catalog.EcosystemBukkit},
},
},
{
Product: ProductPaper,
Ecosystem: catalog.EcosystemPaper,
Aliases: []productAlias{
{"paper", catalog.EcosystemUnspecified},
{"paper", catalog.EcosystemPaper},
},
},
{
Product: ProductPurpur,
Ecosystem: catalog.EcosystemPaper,
Aliases: []productAlias{
{"purpur", catalog.EcosystemUnspecified},
{"purpur", catalog.EcosystemPaper},
},
},
{
Product: ProductFolia,
Ecosystem: catalog.EcosystemPaper,
Aliases: []productAlias{
{"folia", catalog.EcosystemUnspecified},
{"folia", catalog.EcosystemPaper},
},
},
{
Product: ProductLeaves,
Ecosystem: catalog.EcosystemPaper,
Aliases: []productAlias{
{"leaves", catalog.EcosystemUnspecified},
{"leaves", catalog.EcosystemPaper},
},
},
{
Product: ProductArclight,
Aliases: []productAlias{{"arclight", catalog.EcosystemUnspecified}},
},
{
Product: ProductArclightForge,
Aliases: []productAlias{{"arclight-forge", catalog.EcosystemUnspecified}},
},
{
Product: ProductArclightNeoForge,
Aliases: []productAlias{
{
"arclight-neoforge",
catalog.EcosystemUnspecified,
},
},
},
{
Product: ProductArclightFabric,
Aliases: []productAlias{{"arclight-fabric", catalog.EcosystemUnspecified}},
},
{
Product: ProductCatServer,
Aliases: []productAlias{{"catserver", catalog.EcosystemUnspecified}},
},
{
Product: ProductYouer,
Aliases: []productAlias{{"youer", catalog.EcosystemUnspecified}},
},
{
Product: ProductSpongeVanilla,
Ecosystem: catalog.EcosystemSponge,
Aliases: []productAlias{
{"sponge", catalog.EcosystemUnspecified},
{"spongevanilla", catalog.EcosystemUnspecified},
{"sponge", catalog.EcosystemSponge},
{"spongevanilla", catalog.EcosystemSponge},
{"vanilla", catalog.EcosystemSponge},
{"minecraft", catalog.EcosystemSponge},
{"mc", catalog.EcosystemSponge},
},
},
{
Product: ProductSpongeForge,
Ecosystem: catalog.EcosystemSponge,
Aliases: []productAlias{
{"spongeforge", catalog.EcosystemUnspecified},
{"spongeforge", catalog.EcosystemSponge},
{"forge", catalog.EcosystemSponge},
},
},
{
Product: ProductSpongeNeo,
Ecosystem: catalog.EcosystemSponge,
Aliases: []productAlias{
{"spongeneo", catalog.EcosystemUnspecified},
{"spongeneo", catalog.EcosystemSponge},
{"neo", catalog.EcosystemSponge},
{"neoforge", catalog.EcosystemSponge},
},
},
{
Product: ProductBungeeCord,
Ecosystem: catalog.EcosystemBungeeCord,
Aliases: []productAlias{
{"bungeecord", catalog.EcosystemUnspecified},
{"bungeecord", catalog.EcosystemBungeeCord},
},
},
{
Product: ProductVelocity,
Ecosystem: catalog.EcosystemVelocity,
Aliases: []productAlias{
{"velocity", catalog.EcosystemUnspecified},
{"velocity", catalog.EcosystemVelocity},
},
},
{
Product: ProductWaterfall,
Ecosystem: catalog.EcosystemBungeeCord,
Aliases: []productAlias{
{"waterfall", catalog.EcosystemUnspecified},
{"waterfall", catalog.EcosystemBungeeCord},
},
},
}
// NormalizeProduct resolves a request against the server product
// catalog. Provider is intentionally not considered.
func NormalizeProduct(request catalog.VersionedRef) (Match, bool) {
name := catalog.Name(strings.ToLower(request.Name.String()))
for _, definition := range productDefinitions {
for _, alias := range definition.Aliases {
if alias.Name == name && alias.Ecosystem == request.Ecosystem {
return Match{
Product: definition.Product,
Ecosystem: definition.Ecosystem,
}, true
}
}
}
return Match{}, false
}
// ParseProduct resolves a product spelling without an ecosystem
// qualifier. It is used for persisted server declarations.
func ParseProduct(name string) (Product, bool) {
match, ok := NormalizeProduct(catalog.VersionedRef{
Name: catalog.Name(strings.ToLower(strings.TrimSpace(name))),
})
return match.Product, ok
}
func IsProduct(request catalog.VersionedRef) bool {
_, ok := NormalizeProduct(request)
return ok
}
// Package serverregistry locates named workspaces through per-user registry
// entries that record canonical workspace paths.
package serverregistry
import (
"cmp"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/mclucy/lucy/document"
"github.com/mclucy/lucy/internal/fsutil"
)
const (
StateAvailable = "available"
StateLockOnly = "lock-only"
StateMissing = "missing"
StateUnreadable = "unreadable"
StateInvalid = "invalid"
)
// Entry describes a registered path and its current declaration. An unnamed
// server has no name selector. Unavailable entries remain registered.
type Entry struct {
Name string `json:"name"`
Path string `json:"path"`
State string `json:"state"`
Problem string `json:"problem,omitempty"`
}
type Registry struct {
directory string
}
// record is one registry entry file together with the workspace path it
// records. Target is empty when the entry file cannot be interpreted.
type record struct {
file string
target string
entry Entry
}
const registryLockName = "registry.lock"
const (
// entryTempPrefix names the staged file a registration publishes. List
// ignores it, so an abandoned stage is inert debris rather than an entry.
entryTempPrefix = ".tmp-"
// maxEntrySize bounds how much of an entry file List will read.
maxEntrySize = 64 << 10
)
// New locates durable per-user data without creating or changing the registry.
func New() (*Registry, error) {
root, err := DataRoot()
if err != nil {
return nil, err
}
return &Registry{directory: filepath.Join(root, "servers")}, nil
}
// DataRoot reports the per-user directory Lucy owns for durable machine state.
// It matches the platform's local application data conventions. It never
// creates the directory.
func DataRoot() (string, error) {
var base string
switch runtime.GOOS {
case "windows":
base = os.Getenv("LOCALAPPDATA")
if base == "" {
return "", errors.New("locate lucy data directory: LOCALAPPDATA is not set")
}
case "darwin":
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("locate lucy data directory: %w", err)
}
base = filepath.Join(home, "Library", "Application Support")
default:
base = os.Getenv("XDG_DATA_HOME")
if !filepath.IsAbs(base) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("locate lucy data directory: %w", err)
}
base = filepath.Join(home, ".local", "share")
}
}
return filepath.Join(base, "lucy"), nil
}
// List checks every target on each call. It does not prune entries: a missing
// target can be an unmounted volume, and deletion could race registration. An
// entry that cannot be interpreted is reported as invalid, which makes
// collection refuse to run.
func (r *Registry) List() ([]Entry, error) {
records, err := r.records()
if err != nil {
return nil, err
}
entries := make([]Entry, 0, len(records))
for _, record := range records {
entries = append(entries, record.entry)
}
slices.SortFunc(entries, func(a, b Entry) int {
return cmp.Or(strings.Compare(a.Name, b.Name), strings.Compare(a.Path, b.Path))
})
return entries, nil
}
// records reads every registry entry without locking. Callers that must
// observe a stable set hold the registry lock while calling it.
func (r *Registry) records() ([]record, error) {
links, err := os.ReadDir(r.directory)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read server registry: %w", err)
}
records := make([]record, 0, len(links))
for _, link := range links {
if link.Name() == registryLockName ||
strings.HasPrefix(link.Name(), entryTempPrefix) {
continue
}
file := filepath.Join(r.directory, link.Name())
target, err := readEntry(file)
if errors.Is(err, os.ErrNotExist) {
// A concurrent deinitialization removed this entry.
continue
}
if err != nil {
records = append(records, record{
file: file,
entry: Entry{
Path: file,
State: StateInvalid,
Problem: err.Error(),
},
})
continue
}
records = append(records, record{
file: file,
target: target,
entry: observe(target),
})
}
return records, nil
}
// Resolve requires exactly one available declaration with this name.
func (r *Registry) Resolve(name string) (Entry, error) {
name, err := document.NormalizeName(name)
if err != nil {
return Entry{}, err
}
entries, err := r.List()
if err != nil {
return Entry{}, err
}
var match Entry
count := 0
for _, entry := range entries {
if entry.State == StateAvailable && entry.Name == name {
match = entry
count++
}
}
switch count {
case 0:
return Entry{}, fmt.Errorf("no available registered server named %q; run `lucy server ls` to inspect registered paths", name)
case 1:
return match, nil
default:
var message strings.Builder
fmt.Fprintf(&message, "server name %q is ambiguous; matching directories:", name)
for _, entry := range entries {
if entry.State == StateAvailable && entry.Name == name {
fmt.Fprintf(&message, "\n %s", entry.Path)
}
}
message.WriteString("\nrun Lucy in the intended directory or give its manifest a unique name")
return Entry{}, errors.New(message.String())
}
}
// Register creates one entry per canonical workspace path for a workspace with
// a readable manifest. The caller must hold the workspace writer lock so
// deinitialization cannot remove a newer entry. Other available entries with
// the same name are returned for presentation.
func (r *Registry) Register(root string) ([]Entry, error) {
root, err := canonical(root)
if err != nil {
return nil, err
}
entry := observe(root)
if entry.State != StateAvailable {
return nil, fmt.Errorf("register server %s: %s", root, entry.Problem)
}
return r.registerEntry(root, entry)
}
// RegisterInstallation records a workspace whose installation completed. A
// workspace with a readable manifest is registered by name. A lock-only
// installation has no manifest name but still pins artifacts, so it is
// recorded as a lock-only entry that collection tracks and --server does not
// select. The caller must hold the workspace writer lock.
func (r *Registry) RegisterInstallation(root string) ([]Entry, error) {
root, err := canonical(root)
if err != nil {
return nil, err
}
entry := observe(root)
switch entry.State {
case StateAvailable, StateLockOnly:
default:
return nil, fmt.Errorf("register server %s: %s", root, entry.Problem)
}
return r.registerEntry(root, entry)
}
func (r *Registry) registerEntry(root string, entry Entry) ([]Entry, error) {
lock, err := r.lock()
if err != nil {
return nil, fmt.Errorf("lock server registry: %w", err)
}
defer func() { _ = lock.Close() }()
entryPath := filepath.Join(r.directory, pathKey(root))
target, err := readEntry(entryPath)
switch {
case err == nil:
if !samePath(target, root) {
return nil, fmt.Errorf("server registry entry %s does not point to %s", entryPath, root)
}
case errors.Is(err, os.ErrNotExist):
if err := writeEntry(entryPath, root); err != nil {
return nil, fmt.Errorf("register server directory entry: %w", err)
}
default:
return nil, fmt.Errorf("read server registry entry %s: %w", entryPath, err)
}
entries, err := r.List()
if err != nil {
return nil, err
}
if entry.Name == "" {
return nil, nil
}
duplicates := entries[:0]
for _, other := range entries {
if other.State == StateAvailable && other.Name == entry.Name && !samePath(other.Path, root) {
duplicates = append(duplicates, other)
}
}
return duplicates, nil
}
// Unregister removes only this path's entry, never the target directory. It
// must run while the caller still holds the workspace's original writer lock.
func (r *Registry) Unregister(root string) error {
root, err := canonical(root)
if err != nil {
return err
}
if _, err := os.Stat(r.directory); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return fmt.Errorf("inspect server registry: %w", err)
}
lock, err := r.lock()
if err != nil {
return fmt.Errorf("lock server registry: %w", err)
}
defer func() { _ = lock.Close() }()
entryPath := filepath.Join(r.directory, pathKey(root))
target, err := readEntry(entryPath)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read server registry entry: %w", err)
}
if !samePath(target, root) {
return fmt.Errorf("server registry entry %s does not point to %s", entryPath, root)
}
if err := os.Remove(entryPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("unregister server: %w", err)
}
return nil
}
// Forget removes the single registry entry whose recorded target is path. The
// target must be confirmed missing; a live, unreadable, or ambiguous target
// leaves the registry unchanged. The caller passes the path as List reports
// it, because canonicalizing a missing target cannot succeed. Forget holds the
// registry mutation lock and removes only the entry file, never the target or
// any other data.
func (r *Registry) Forget(path string) (Entry, error) {
if strings.TrimSpace(path) == "" {
return Entry{}, errors.New("forget server: path is empty")
}
target, err := filepath.Abs(path)
if err != nil {
return Entry{}, fmt.Errorf("resolve server directory: %w", err)
}
target = filepath.Clean(target)
if _, err := os.Stat(r.directory); errors.Is(err, os.ErrNotExist) {
return Entry{}, fmt.Errorf(
"no registered server at %s; run `lucy server ls` to inspect registered paths",
target,
)
} else if err != nil {
return Entry{}, fmt.Errorf("inspect server registry: %w", err)
}
lock, err := r.lock()
if err != nil {
return Entry{}, fmt.Errorf("lock server registry: %w", err)
}
defer func() { _ = lock.Close() }()
records, err := r.records()
if err != nil {
return Entry{}, err
}
matches := make([]record, 0, 1)
for _, record := range records {
if record.target != "" && samePath(filepath.Clean(record.target), target) {
matches = append(matches, record)
}
}
switch len(matches) {
case 0:
return Entry{}, fmt.Errorf(
"no registered server at %s; run `lucy server ls` to inspect registered paths",
target,
)
case 1:
default:
files := make([]string, 0, len(matches))
for _, match := range matches {
files = append(files, match.file)
}
return Entry{}, fmt.Errorf(
"server registry has %d entries for %s: %s",
len(matches), target, strings.Join(files, ", "),
)
}
match := matches[0]
if match.entry.State != StateMissing {
switch match.entry.State {
case StateAvailable, StateLockOnly:
return Entry{}, fmt.Errorf(
"registered server %s still exists; run `lucy deinit` in that directory to remove it",
target,
)
default:
return Entry{}, fmt.Errorf(
"cannot confirm registered server %s is missing (%s): %s",
target, match.entry.State, match.entry.Problem,
)
}
}
current, err := readEntry(match.file)
if errors.Is(err, os.ErrNotExist) {
return Entry{}, errors.New("server registry entry changed during forget; retry")
}
if err != nil {
return Entry{}, fmt.Errorf("read server registry entry: %w", err)
}
if !samePath(filepath.Clean(current), target) {
return Entry{}, errors.New("registered server changed during forget; retry")
}
if err := confirmMissing(target); err != nil {
return Entry{}, err
}
if err := os.Remove(match.file); err != nil {
if errors.Is(err, os.ErrNotExist) {
return Entry{}, errors.New("server registry entry changed during forget; retry")
}
return Entry{}, fmt.Errorf("forget server: %w", err)
}
return match.entry, nil
}
// confirmMissing requires the recorded path itself to be absent. Lstat is
// deliberate: a dangling symlink still occupies the path and is not the
// directory that registration recorded.
func confirmMissing(target string) error {
if _, err := os.Lstat(target); err == nil {
return fmt.Errorf(
"registered path %s exists and cannot be confirmed missing",
target,
)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("cannot confirm registered path %s is missing: %w", target, err)
}
return nil
}
// Lock holds the registry mutation lock. Registration, unregistration, and
// forgetting use the same lock, so a caller can observe a stable registered
// set while it holds this lock. The lock file lives beside the entries and is
// skipped by List.
func (r *Registry) Lock() (*fsutil.FileLock, error) {
return r.lock()
}
func (r *Registry) lock() (*fsutil.FileLock, error) {
if err := fsutil.MkdirAllPrivate(r.directory); err != nil {
return nil, fmt.Errorf("create server registry: %w", err)
}
return fsutil.LockFile(filepath.Join(r.directory, registryLockName))
}
func observe(root string) Entry {
entry := Entry{Path: root, State: StateAvailable}
info, err := os.Stat(root)
if err != nil {
entry.State = StateUnreadable
if errors.Is(err, os.ErrNotExist) {
entry.State = StateMissing
}
entry.Problem = err.Error()
return entry
}
if !info.IsDir() {
entry.State = StateInvalid
entry.Problem = "registered target is not a directory"
return entry
}
manifest, err := document.ManifestFile(root).Read()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
if _, lockErr := document.LockFile(root).Read(); lockErr == nil {
entry.State = StateLockOnly
return entry
} else if !errors.Is(lockErr, os.ErrNotExist) {
entry.State = StateUnreadable
entry.Problem = "read lockfile: " + lockErr.Error()
return entry
}
}
entry.State = StateInvalid
if _, pathError := errors.AsType[*os.PathError](err); pathError && !errors.Is(err, os.ErrNotExist) {
entry.State = StateUnreadable
}
entry.Problem = err.Error()
return entry
}
entry.Name = manifest.Name
return entry
}
func readEntry(path string) (string, error) {
info, err := os.Lstat(path)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", errors.New("registry entry is not a regular file")
}
if info.Size() > maxEntrySize {
return "", fmt.Errorf("registry entry exceeds %d bytes", maxEntrySize)
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
if !filepath.IsAbs(string(data)) {
return "", errors.New("registry entry does not contain an absolute path")
}
return string(data), nil
}
// writeEntry publishes a complete entry in one rename, so an unlocked List
// observes either the previous entry or the full target, never a partial file.
// An abandoned stage keeps the prefix List ignores.
func writeEntry(path, target string) (resultErr error) {
staged, err := os.CreateTemp(filepath.Dir(path), entryTempPrefix)
if err != nil {
return fmt.Errorf("stage server registry entry: %w", err)
}
stagedPath := staged.Name()
defer func() {
if resultErr != nil {
_ = os.Remove(stagedPath)
}
}()
if _, err := staged.WriteString(target); err != nil {
return errors.Join(err, staged.Close())
}
if err := staged.Sync(); err != nil {
return errors.Join(err, staged.Close())
}
if err := staged.Close(); err != nil {
return err
}
if err := os.Rename(stagedPath, path); err != nil {
return fmt.Errorf("publish server registry entry: %w", err)
}
return nil
}
func canonical(root string) (string, error) {
absolute, err := filepath.Abs(root)
if err != nil {
return "", fmt.Errorf("resolve server directory: %w", err)
}
resolved, err := filepath.EvalSymlinks(absolute)
if err != nil {
return "", fmt.Errorf("resolve server directory: %w", err)
}
return resolved, nil
}
func pathKey(root string) string {
if runtime.GOOS == "windows" {
root = strings.ToLower(root)
}
digest := sha256.Sum256([]byte(root))
return hex.EncodeToString(digest[:])
}
func samePath(a, b string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(a, b)
}
return a == b
}
package telemetry
import (
"context"
"errors"
"net/http"
"strings"
"time"
)
// Client is a concurrency-safe telemetry client. All state lives beneath its
// private directory. Create one client per process.
type Client struct {
disabled bool
directory string
endpoint string
channel string
version string
commit string
os string
arch string
logger Logger
http *http.Client
ownsHTTP bool
now func() time.Time
limits Limits
sem chan struct{}
}
// New validates config and builds a client. It performs no disk or network
// I/O; the first event or explicit operation initializes state lazily.
func New(config Config) (*Client, error) {
config, err := config.withDefaults()
if err != nil {
return nil, err
}
httpClient := config.HTTPClient
ownsHTTP := false
if httpClient == nil {
if transport, ok := http.DefaultTransport.(*http.Transport); ok {
httpClient = &http.Client{
Transport: transport.Clone(),
Timeout: config.Limits.RequestTimeout,
CheckRedirect: safeRedirect,
}
} else {
httpClient = &http.Client{Timeout: config.Limits.RequestTimeout, CheckRedirect: safeRedirect}
}
ownsHTTP = true
} else {
cloned := *httpClient
previous := cloned.CheckRedirect
cloned.CheckRedirect = func(request *http.Request, via []*http.Request) error {
if err := safeRedirect(request, via); err != nil {
return err
}
if previous != nil {
return previous(request, via)
}
return nil
}
httpClient = &cloned
}
return &Client{
disabled: config.Disabled,
directory: config.Directory,
endpoint: config.Endpoint,
channel: config.Channel,
version: config.LucyVersion,
commit: config.LucyCommit,
os: config.OS,
arch: config.Arch,
logger: config.Logger,
http: httpClient,
ownsHTTP: ownsHTTP,
now: config.Now,
limits: config.Limits,
sem: make(chan struct{}, 1),
}, nil
}
// Close releases idle HTTP connections owned by the client. It never touches
// telemetry state.
func (c *Client) Close() error {
if c.ownsHTTP && c.http != nil {
c.http.CloseIdleConnections()
}
return nil
}
// Enabled reports whether collection is currently allowed. It does not report
// whether an endpoint is configured; Flush handles that separately. An empty
// endpoint still permits durable local enqueueing.
func (c *Client) Enabled(ctx context.Context) (bool, error) {
if c.disabled || environmentDisabled() {
return false, nil
}
release, err := c.enterOperation(ctx, c.limits.LockWait)
if err != nil {
return false, err
}
defer release()
state, _, err := c.loadState(c.now())
if err != nil {
return false, err
}
enabled, _, _ := c.effective(state)
return enabled, nil
}
// InstallationID returns the persistent installation UUID, creating it on
// first startup even when telemetry is disabled.
func (c *Client) InstallationID(ctx context.Context) (string, error) {
release, err := c.enterOperation(ctx, c.limits.LockWait)
if err != nil {
return "", err
}
defer release()
now := c.now()
state, _, err := c.loadState(now)
if err != nil {
return "", err
}
if state.InstallationID != "" {
return state.InstallationID, nil
}
state.ensureIdentity(now)
if err := c.saveState(state, now); err != nil {
return "", err
}
return state.InstallationID, nil
}
// RecordEvent records one invocation observation. It never uploads; call
// Flush separately when networking is allowed. Callers own InvocationID.
func (c *Client) RecordEvent(ctx context.Context, event Event) (Submission, error) {
return c.enqueue(ctx, recordTypeEvent, func(state *persistedState, _ string, now time.Time) (payloadResult, error) {
prepared, err := c.prepareEvent(event, now)
if err != nil {
return payloadResult{}, err
}
return payloadResult{Payload: c.buildEventPayload(prepared, state)}, nil
})
}
// SubmitReport records one explicitly supplied report. It sanitizes and bounds
// the body, never uploads, and never enables telemetry. When telemetry is off,
// it returns a non-error Submission whose Reason explains the refusal; the
// body is not inspected in that case.
func (c *Client) SubmitReport(ctx context.Context, report Report) (Submission, error) {
return c.enqueue(ctx, recordTypeReport, func(state *persistedState, id string, now time.Time) (payloadResult, error) {
prepared, truncated, err := c.prepareReport(report, now)
if err != nil {
return payloadResult{}, err
}
return payloadResult{
Payload: reportPayload{
ReportID: id,
InstallationID: state.InstallationID,
InvocationID: prepared.invocationID,
Kind: string(prepared.kind),
Agent: prepared.agent,
Model: prepared.model,
CreatedAt: prepared.createdAt.Format(time.RFC3339),
SchemaVersion: SchemaVersion,
CollectionPolicy: payloadPolicy(state.Policy),
Body: prepared.body,
},
BodyBytes: int64(len(prepared.body)),
Truncated: truncated,
}, nil
})
}
// SetEnabled persists an explicit opt-in or opt-out. Disabling purges queued
// records without rotating the identity and waits for any in-flight upload in
// this process or another cooperating process. Enabling creates an identity if
// one does not exist. It does not upload.
func (c *Client) SetEnabled(ctx context.Context, enabled bool) error {
release, err := c.enterOperation(ctx, -1)
if err != nil {
return err
}
defer release()
now := c.now()
state, _, err := c.loadState(now)
if err != nil {
if enabled || !errors.Is(err, errCorruptState) {
return err
}
state = newState(now)
}
if enabled {
state.optIn(now)
} else {
state.optOut()
}
if err := c.saveState(state, now); err != nil {
return err
}
if !enabled {
return c.finishDisabledPurge(state, now)
}
return nil
}
// ResetIdentity rotates the installation UUID and restarts the statistics
// window. Pending records keep their IDs and payloads and may be accepted
// later; they count in the new window. Remote data is never deleted.
func (c *Client) ResetIdentity(ctx context.Context) (Identity, error) {
release, err := c.enterOperation(ctx, -1)
if err != nil {
return Identity{}, err
}
defer release()
now := c.now()
state, _, err := c.loadState(now)
if err != nil {
return Identity{}, err
}
state.resetIdentity(now)
if err := c.saveState(state, now); err != nil {
return Identity{}, err
}
return Identity{
InstallationID: state.InstallationID,
CreatedAt: state.IdentityCreatedAt,
Policy: state.Policy,
}, nil
}
// Stats returns a transparent snapshot. Reading statistics may initialize a
// default state document but never creates an installation identity or sends
// anything.
func (c *Client) Stats(ctx context.Context) (Stats, error) {
stats := Stats{
Policy: CollectionDefault,
Endpoint: c.endpoint,
EndpointConfigured: c.endpoint != "",
StateDirectory: c.directory,
}
release, err := c.enterOperation(ctx, c.limits.LockWait)
if err != nil {
return stats, err
}
defer release()
state, existed, err := c.loadState(c.now())
if err != nil {
return stats, err
}
enabled, policy, reason := c.effective(state)
stats.Enabled = enabled
stats.Reason = reason
stats.Policy = policy
stats.InstallationID = state.InstallationID
if existed {
stats.PeriodStart = state.PeriodStart
}
stats.Accepted = AcceptedStats{
Events: state.Accepted.Events,
Reports: state.Accepted.Reports,
Records: state.Accepted.Events + state.Accepted.Reports,
EventPayloadBytes: state.Accepted.EventPayloadBytes,
ReportBodyBytes: state.Accepted.ReportBodyBytes,
}
stats.Rejected = RejectedStats(state.Rejected)
stats.Dropped = DroppedStats(state.Dropped)
stats.Transport = TransportStats(state.Transport)
stats.Queue = queueStatistics(state)
stats.CooldownUntil = state.CooldownUntil
stats.LastError = state.LastError
return stats, nil
}
func (c *Client) effective(state *persistedState) (bool, CollectionPolicy, SkipReason) {
policy := state.Policy
if policy == "" {
policy = CollectionDefault
}
if c.disabled {
return false, policy, SkipDisabled
}
if environmentDisabled() {
return false, policy, SkipEnvironment
}
switch policy {
case CollectionOptOut:
return false, policy, SkipDisabled
case CollectionOptIn:
return true, policy, SkipNone
default:
if channelCollectsByDefault(c.channel) {
return true, policy, SkipNone
}
return false, policy, SkipDisabled
}
}
func payloadPolicy(policy CollectionPolicy) string {
if policy == CollectionOptIn {
return string(CollectionOptIn)
}
return string(CollectionDefault)
}
func (c *Client) debug(message string, fields ...any) {
if c.logger != nil {
c.logger.Debug(message, fields...)
}
}
func (c *Client) warn(err error) {
if c.logger != nil && err != nil {
c.logger.Warn(err)
}
}
func ignoreLockBusy(err error) bool {
return errors.Is(err, errLockBusy)
}
// safeRedirect keeps telemetry on the configured origin.
func safeRedirect(request *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return errors.New("telemetry: too many redirects")
}
if len(via) > 0 && (!strings.EqualFold(request.URL.Host, via[0].URL.Host) ||
!strings.EqualFold(request.URL.Scheme, via[0].URL.Scheme)) {
return errors.New("telemetry: redirect must stay on the configured origin")
}
if request.URL.Scheme == "https" {
return nil
}
if request.URL.Scheme == "http" && isLoopbackHost(request.URL.Hostname()) {
return nil
}
return errors.New("telemetry: redirect must use https outside loopback")
}
// Package telemetry implements Lucy's opt-out usage telemetry client.
//
// The client owns a private, per-user state directory. It records bounded,
// typed event records and explicit reports into a durable local queue, and
// uploads them only when the caller invokes Flush. It never starts a daemon,
// never inspects files outside its own state directory, and never collects
// process arguments, environment variables, logs, or arbitrary host data.
//
// Telemetry is enabled by default for pre-release channels (alpha and beta).
// LUCY_TELEMETRY can force it off for a process. Persisted opt-in and opt-out
// choices override channel defaults. Disabling telemetry (SetEnabled with
// false) purges queued records without rotating the installation identity.
// Resetting the identity rotates the UUID and restarts the statistics window,
// but keeps queued records and their original identities.
//
// Every error returned by this package is telemetry-local. A CLI must ignore
// it: telemetry failure never changes the result of a regular command.
package telemetry
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
// EnvironmentVariable forces telemetry off when set to a false-like value.
// Recognized false values are 0, off, false, no, disabled, opt-out, and
// opt_out. An unrecognized non-empty value also disables telemetry (privacy
// favours failing closed). Recognized true values are 1, on, true, yes,
// enabled, opt-in, and opt_in; they never override a persisted opt-out.
const EnvironmentVariable = "LUCY_TELEMETRY"
// SchemaVersion is the wire and local-state schema version.
const SchemaVersion = 1
// DefaultEndpoint is the public Lucy telemetry ingestion endpoint. Builds may
// override it with a linker flag for a separate deployment.
var DefaultEndpoint = "https://telemetry.lucy.lc"
const (
stateDirectoryName = "telemetry"
defaultChannel = "alpha"
defaultMaxQueuedRecords = 512
defaultMaxQueuedBytes = 4 << 20
defaultMaxEventPayloadBytes = 16 << 10
defaultMaxReportBodyBytes = 64 << 10
defaultMaxRecordsPerRequest = 50
defaultMaxRequestBytes = 256 << 10
defaultMaxRecordBytes = 64 << 10
defaultMaxAcceptedIDHistory = 2048
defaultMaxRecordAge = 7 * 24 * time.Hour
defaultHealthTimeout = 2 * time.Second
defaultRequestTimeout = 5 * time.Second
defaultMaxAttempts = 3
defaultRetryBackoff = 250 * time.Millisecond
defaultCooldown = 10 * time.Minute
defaultLockWait = 2 * time.Second
defaultMaxBatches = 4
defaultFlushTimeout = 15 * time.Second
enqueueLockWait = 250 * time.Millisecond
truncationMarker = "\n[truncated]"
)
// Logger is the subset of the Lucy logger used by the client. A *log.Logger
// satisfies it.
type Logger interface {
Debug(content any, fields ...any)
Warn(err error)
}
// Limits bounds local collection and network requests. Zero values select the
// default. MaxRecordBytes applies to one serialized wire record, including its
// JSON envelope. MaxRequestBytes applies to one serialized upload request.
type Limits struct {
MaxQueuedRecords int
MaxQueuedBytes int64
MaxEventPayloadBytes int
MaxReportBodyBytes int
MaxRecordsPerRequest int
MaxRequestBytes int
MaxRecordBytes int
MaxRecordAge time.Duration
HealthTimeout time.Duration
RequestTimeout time.Duration
MaxAttempts int
RetryBackoff time.Duration
Cooldown time.Duration
LockWait time.Duration
MaxBatches int
FlushTimeout time.Duration
}
// Config configures a Client. Directory, Endpoint, and Channel default to the
// platform user data directory, DefaultEndpoint, and alpha. Endpoint must use
// HTTPS unless it points at a loopback host, which keeps tests hermetic.
type Config struct {
Disabled bool
Directory string
Endpoint string
Channel string
LucyVersion string
LucyCommit string
OS string
Arch string
Logger Logger
HTTPClient *http.Client
Now func() time.Time
Limits Limits
}
func (config Config) withDefaults() (Config, error) {
if config.Now == nil {
config.Now = time.Now
}
if strings.TrimSpace(config.Directory) == "" {
directory, err := defaultDirectory()
if err != nil {
return Config{}, err
}
config.Directory = directory
}
config.Directory = filepath.Clean(config.Directory)
config.Channel = strings.ToLower(strings.TrimSpace(config.Channel))
if config.Channel == "" {
config.Channel = defaultChannel
}
if !validChannel(config.Channel) {
return Config{}, fmt.Errorf("telemetry: invalid channel %q", config.Channel)
}
config.LucyVersion = sanitizeVersion(config.LucyVersion)
config.LucyCommit = sanitizeCommit(config.LucyCommit)
if config.OS == "" {
config.OS = runtime.GOOS
}
if config.Arch == "" {
config.Arch = runtime.GOARCH
}
config.OS = sanitizeToken(config.OS, 32)
config.Arch = sanitizeToken(config.Arch, 32)
endpoint := strings.TrimSpace(config.Endpoint)
if endpoint == "" {
endpoint = strings.TrimSpace(DefaultEndpoint)
}
normalizedEndpoint, err := normalizeEndpoint(endpoint)
if err != nil {
return Config{}, err
}
config.Endpoint = normalizedEndpoint
config.Limits = config.Limits.withDefaults()
return config, nil
}
func (limits Limits) withDefaults() Limits {
if limits.MaxQueuedRecords <= 0 {
limits.MaxQueuedRecords = defaultMaxQueuedRecords
}
if limits.MaxQueuedBytes <= 0 {
limits.MaxQueuedBytes = defaultMaxQueuedBytes
}
if limits.MaxEventPayloadBytes <= 0 {
limits.MaxEventPayloadBytes = defaultMaxEventPayloadBytes
}
if limits.MaxReportBodyBytes <= 0 {
limits.MaxReportBodyBytes = defaultMaxReportBodyBytes
}
if limits.MaxRecordsPerRequest <= 0 {
limits.MaxRecordsPerRequest = defaultMaxRecordsPerRequest
}
if limits.MaxRequestBytes <= 0 {
limits.MaxRequestBytes = defaultMaxRequestBytes
}
if limits.MaxRecordBytes <= 0 {
limits.MaxRecordBytes = defaultMaxRecordBytes
}
if limits.MaxRecordAge <= 0 {
limits.MaxRecordAge = defaultMaxRecordAge
}
if limits.HealthTimeout <= 0 {
limits.HealthTimeout = defaultHealthTimeout
}
if limits.RequestTimeout <= 0 {
limits.RequestTimeout = defaultRequestTimeout
}
if limits.MaxAttempts <= 0 {
limits.MaxAttempts = defaultMaxAttempts
}
if limits.RetryBackoff <= 0 {
limits.RetryBackoff = defaultRetryBackoff
}
if limits.Cooldown <= 0 {
limits.Cooldown = defaultCooldown
}
if limits.LockWait < 0 {
limits.LockWait = 0
} else if limits.LockWait == 0 {
limits.LockWait = defaultLockWait
}
if limits.MaxBatches <= 0 {
limits.MaxBatches = defaultMaxBatches
}
if limits.FlushTimeout <= 0 {
limits.FlushTimeout = defaultFlushTimeout
}
// One record must always fit in a request and in the local queue.
if limits.MaxRequestBytes < limits.MaxRecordBytes+1024 {
limits.MaxRequestBytes = limits.MaxRecordBytes + 1024
}
if limits.MaxQueuedBytes < int64(limits.MaxRecordBytes) {
limits.MaxQueuedBytes = int64(limits.MaxRecordBytes)
}
if limits.MaxReportBodyBytes > limits.MaxRecordBytes {
limits.MaxReportBodyBytes = limits.MaxRecordBytes
}
return limits
}
func defaultDirectory() (string, error) {
var base string
switch runtime.GOOS {
case "windows":
base = os.Getenv("LOCALAPPDATA")
if base == "" {
return "", errors.New("telemetry: locate state directory: LOCALAPPDATA is not set")
}
case "darwin":
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("telemetry: locate state directory: %w", err)
}
base = filepath.Join(home, "Library", "Application Support")
default:
base = os.Getenv("XDG_DATA_HOME")
if !filepath.IsAbs(base) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("telemetry: locate state directory: %w", err)
}
base = filepath.Join(home, ".local", "share")
}
}
return filepath.Join(base, "lucy", stateDirectoryName), nil
}
func normalizeEndpoint(raw string) (string, error) {
if raw == "" {
return "", nil
}
parsed, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("telemetry: invalid endpoint: %w", err)
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
if parsed.Scheme != "https" && parsed.Scheme != "http" {
return "", errors.New("telemetry: endpoint must use https")
}
if parsed.Host == "" {
return "", errors.New("telemetry: endpoint has no host")
}
if parsed.Scheme == "http" && !isLoopbackHost(parsed.Hostname()) {
return "", errors.New("telemetry: endpoint must use https outside loopback")
}
if parsed.User != nil {
return "", errors.New("telemetry: endpoint must not contain credentials")
}
if parsed.Opaque != "" || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
return "", errors.New("telemetry: endpoint must not contain a query or fragment")
}
return strings.TrimRight(parsed.String(), "/"), nil
}
func isLoopbackHost(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
address := net.ParseIP(host)
return address != nil && address.IsLoopback()
}
func channelCollectsByDefault(channel string) bool {
return channel == "alpha" || channel == "beta"
}
func environmentDisabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvironmentVariable))) {
case "":
return false
case "0", "off", "false", "no", "disabled", "opt-out", "opt_out":
return true
case "1", "on", "true", "yes", "enabled", "opt-in", "opt_in":
return false
default:
// An unrecognized explicit value is treated as a request to stop.
return true
}
}
func sanitizeVersion(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if len(raw) > 128 {
raw = raw[:128]
}
var builder strings.Builder
for _, character := range raw {
switch {
case character >= 'a' && character <= 'z':
case character >= 'A' && character <= 'Z':
case character >= '0' && character <= '9':
case strings.ContainsRune("._+-", character):
default:
return sanitizeToken(raw, 64)
}
builder.WriteRune(character)
if builder.Len() >= 64 {
break
}
}
return builder.String()
}
func sanitizeCommit(raw string) string {
raw = strings.ToLower(strings.TrimSpace(raw))
if len(raw) < 7 || len(raw) > 40 {
return ""
}
for _, character := range raw {
if (character < '0' || character > '9') && (character < 'a' || character > 'f') {
return ""
}
}
return raw
}
package telemetry
import (
"encoding/json/jsontext"
"fmt"
"strings"
"time"
)
const (
maxContexts = 16
maxContextDataBytes = 8 << 10
maxContextTypeLength = 64
maxEventDuration = 30 * 24 * time.Hour
)
type eventPayload struct {
InvocationID string `json:"invocation_id"`
InstallationID string `json:"installation_id"`
OccurredAt string `json:"occurred_at"`
Command string `json:"command"`
LucyVersion string `json:"lucy_version"`
LucyCommit string `json:"lucy_commit"`
Channel string `json:"channel"`
OS string `json:"os"`
Arch string `json:"arch"`
DurationMS int64 `json:"duration_ms"`
Outcome Outcome `json:"outcome"`
ErrorCode string `json:"error_code"`
SchemaVersion int `json:"schema_version"`
CollectionPolicy string `json:"collection_policy"`
Contexts []contextPayload `json:"contexts,omitempty"`
}
type contextPayload struct {
Type string `json:"type"`
Status ContextStatus `json:"status"`
Data jsontext.Value `json:"data,omitzero"`
}
type reportPayload struct {
ReportID string `json:"report_id"`
InstallationID string `json:"installation_id"`
InvocationID string `json:"invocation_id,omitempty"`
Kind string `json:"kind"`
Agent string `json:"agent,omitempty"`
Model string `json:"model,omitempty"`
CreatedAt string `json:"created_at"`
SchemaVersion int `json:"schema_version"`
CollectionPolicy string `json:"collection_policy"`
Body string `json:"body"`
}
type preparedEvent struct {
invocationID string
command string
durationMS int64
outcome Outcome
errorCode string
occurredAt time.Time
contexts []contextPayload
}
type preparedReport struct {
kind ReportKind
invocationID string
agent string
model string
body string
createdAt time.Time
}
func (c *Client) prepareEvent(event Event, now time.Time) (preparedEvent, error) {
invocationID, err := normalizeUUID(event.InvocationID)
if err != nil {
return preparedEvent{}, fmt.Errorf("%w: invocation id: %v", ErrInvalidEvent, err)
}
command, err := normalizeCommand(event.Command)
if err != nil {
return preparedEvent{}, fmt.Errorf("%w: command must be a command path without arguments", ErrInvalidEvent)
}
if event.Duration < 0 || event.Duration > maxEventDuration {
return preparedEvent{}, fmt.Errorf("%w: duration out of range", ErrInvalidEvent)
}
switch event.Outcome {
case OutcomeSuccess, OutcomeFailure, OutcomeCanceled:
default:
return preparedEvent{}, fmt.Errorf("%w: unknown outcome %q", ErrInvalidEvent, event.Outcome)
}
if event.ErrorCode != "" && !validCode(event.ErrorCode, 64) {
return preparedEvent{}, fmt.Errorf("%w: invalid error code", ErrInvalidEvent)
}
contexts, err := normalizeContexts(event.Contexts)
if err != nil {
return preparedEvent{}, err
}
occurredAt := event.OccurredAt
if occurredAt.IsZero() {
occurredAt = now
}
return preparedEvent{
invocationID: invocationID,
command: command,
durationMS: event.Duration.Milliseconds(),
outcome: event.Outcome,
errorCode: event.ErrorCode,
occurredAt: occurredAt.UTC(),
contexts: contexts,
}, nil
}
func normalizeContexts(contexts []Context) ([]contextPayload, error) {
if len(contexts) == 0 {
return nil, nil
}
if len(contexts) > maxContexts {
return nil, fmt.Errorf("%w: too many contexts", ErrInvalidEvent)
}
normalized := make([]contextPayload, 0, len(contexts))
for _, context := range contexts {
kind := strings.ToLower(strings.TrimSpace(string(context.Kind)))
if !validContextType(kind) {
return nil, fmt.Errorf("%w: invalid context type", ErrInvalidEvent)
}
switch context.Status {
case ContextPresent, ContextAbsent, ContextFailed:
default:
return nil, fmt.Errorf("%w: invalid context status", ErrInvalidEvent)
}
payload := contextPayload{Type: kind, Status: context.Status}
if context.Status == ContextAbsent {
if context.Data != nil {
return nil, fmt.Errorf("%w: absent context must not carry data", ErrInvalidEvent)
}
normalized = append(normalized, payload)
continue
}
if context.Data == nil {
if context.Status == ContextPresent {
return nil, fmt.Errorf("%w: present context requires data", ErrInvalidEvent)
}
normalized = append(normalized, payload)
continue
}
encoded, err := marshalCanonical(context.Data)
if err != nil {
return nil, fmt.Errorf("%w: context data is not JSON: %v", ErrInvalidEvent, err)
}
if len(encoded) == 0 || string(encoded) == "null" {
if context.Status == ContextPresent {
return nil, fmt.Errorf("%w: present context requires data", ErrInvalidEvent)
}
normalized = append(normalized, payload)
continue
}
if len(encoded) > maxContextDataBytes {
return nil, fmt.Errorf("%w: context data exceeds %d bytes", ErrInvalidEvent, maxContextDataBytes)
}
payload.Data = jsontext.Value(encoded)
normalized = append(normalized, payload)
}
return normalized, nil
}
func validContextType(value string) bool {
if value == "" || len(value) > maxContextTypeLength {
return false
}
for index := 0; index < len(value); index++ {
character := value[index]
valid := character >= 'a' && character <= 'z' ||
character >= '0' && character <= '9'
if index > 0 {
valid = valid || character == '-' || character == '_' || character == '.'
}
if !valid {
return false
}
}
return true
}
func (c *Client) buildEventPayload(event preparedEvent, state *persistedState) eventPayload {
return eventPayload{
InvocationID: event.invocationID,
InstallationID: state.InstallationID,
OccurredAt: event.occurredAt.Format(time.RFC3339),
Command: event.command,
LucyVersion: c.version,
LucyCommit: c.commit,
Channel: c.channel,
OS: c.os,
Arch: c.arch,
DurationMS: event.durationMS,
Outcome: event.outcome,
ErrorCode: event.errorCode,
SchemaVersion: SchemaVersion,
CollectionPolicy: payloadPolicy(state.Policy),
Contexts: event.contexts,
}
}
package telemetry
import (
"context"
"errors"
"fmt"
"os"
"time"
)
var errLockBusy = errors.New("telemetry: state is busy")
type stateLock struct {
file *os.File
}
func (lock *stateLock) release() {
unlockFile(lock.file)
_ = lock.file.Close()
}
// enterOperation serializes one state mutation inside the process and, when
// supported, across processes. wait semantics: zero tries once, a positive
// duration is one budget shared by both layers, and a negative duration waits
// until ctx ends.
func (c *Client) enterOperation(ctx context.Context, wait time.Duration) (func(), error) {
if ctx == nil {
ctx = context.Background()
}
var deadline time.Time
if wait > 0 {
deadline = time.Now().Add(wait)
}
if err := c.acquireProcessSlot(ctx, wait, deadline); err != nil {
return nil, err
}
lock, err := c.lockState(ctx, remainingWait(wait, deadline))
if err != nil {
<-c.sem
return nil, err
}
return func() {
lock.release()
<-c.sem
}, nil
}
// acquireProcessSlot claims the in-process slot under the same wait budget as
// the file lock, so a caller that asked not to wait never blocks on a
// concurrent operation.
func (c *Client) acquireProcessSlot(ctx context.Context, wait time.Duration, deadline time.Time) error {
if wait == 0 {
select {
case c.sem <- struct{}{}:
return nil
default:
return errLockBusy
}
}
var expired <-chan time.Time
if wait > 0 {
timer := time.NewTimer(time.Until(deadline))
defer timer.Stop()
expired = timer.C
}
select {
case c.sem <- struct{}{}:
return nil
case <-expired:
return errLockBusy
case <-ctx.Done():
return ctx.Err()
}
}
// remainingWait reports how much of the wait budget is left for the next lock
// layer. Zero means the budget is spent, so that layer tries once.
func remainingWait(wait time.Duration, deadline time.Time) time.Duration {
if wait <= 0 {
return wait
}
return max(time.Until(deadline), 0)
}
func (c *Client) lockState(ctx context.Context, wait time.Duration) (*stateLock, error) {
if err := c.ensureDirectories(); err != nil {
return nil, err
}
file, err := os.OpenFile(c.lockPath(), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, fmt.Errorf("telemetry: open lock: %w", err)
}
if err := file.Chmod(0o600); err != nil {
_ = file.Close()
return nil, fmt.Errorf("telemetry: secure lock: %w", err)
}
var deadline time.Time
if wait > 0 {
deadline = time.Now().Add(wait)
}
for {
locked, lockErr := tryFileLock(file)
if lockErr != nil {
_ = file.Close()
return nil, fmt.Errorf("telemetry: lock state: %w", lockErr)
}
if locked {
return &stateLock{file: file}, nil
}
if wait == 0 {
_ = file.Close()
return nil, errLockBusy
}
if wait > 0 && !time.Now().Before(deadline) {
_ = file.Close()
return nil, errLockBusy
}
poll := 20 * time.Millisecond
if wait > 0 {
poll = min(poll, max(time.Until(deadline), 0))
}
timer := time.NewTimer(poll)
select {
case <-ctx.Done():
timer.Stop()
_ = file.Close()
return nil, ctx.Err()
case <-timer.C:
}
}
}
//go:build linux || darwin || freebsd || netbsd || openbsd || dragonfly
package telemetry
import (
"errors"
"os"
"golang.org/x/sys/unix"
)
func tryFileLock(file *os.File) (bool, error) {
err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB)
if err == nil {
return true, nil
}
if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
return false, nil
}
return false, err
}
func unlockFile(file *os.File) {
_ = unix.Flock(int(file.Fd()), unix.LOCK_UN)
}
package telemetry
import (
"errors"
"fmt"
"os"
"path/filepath"
)
// noticeMarkerName is the file whose presence means the one-time telemetry
// notice was already presented on this machine. The misspelling is deliberate:
// the file name is a compatibility surface, so correcting it would present the
// notice again on every machine that already dismissed it.
const noticeMarkerName = ".alpha_telemety_notice_shown"
// ClaimNotice reports whether this process must present the one-time telemetry
// notice, and records the claim so no later process presents it again.
//
// The claim is the exclusive creation of the marker file beside the local
// state, so invocations that start together present the notice at most once.
// The marker exists before the caller renders anything: a claim is permanent
// even when the caller cannot print.
func (c *Client) ClaimNotice() (bool, error) {
if c.disabled {
return false, nil
}
if err := os.MkdirAll(c.directory, 0o700); err != nil {
return false, fmt.Errorf("telemetry: create state directory: %w", err)
}
file, err := os.OpenFile(
filepath.Join(c.directory, noticeMarkerName),
os.O_CREATE|os.O_EXCL|os.O_WRONLY,
0o600,
)
if err != nil {
if errors.Is(err, os.ErrExist) {
return false, nil
}
return false, fmt.Errorf("telemetry: claim notice: %w", err)
}
_ = file.Close()
syncDirectory(c.directory)
return true, nil
}
package telemetry
import (
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type recordType string
const (
recordTypeEvent recordType = payloadTypeEvent
recordTypeReport recordType = payloadTypeReport
)
// queuedRecord is one durable outbox entry. The payload is stored verbatim so
// retries send identical bytes with the same record ID.
type queuedRecord struct {
SchemaVersion int `json:"schema_version"`
ID string `json:"id"`
Type recordType `json:"type"`
CreatedAt time.Time `json:"created_at"`
BodyBytes int64 `json:"body_bytes,omitempty"`
Payload jsontext.Value `json:"payload"`
}
type payloadResult struct {
Payload any
BodyBytes int64
Truncated bool
}
type payloadBuilder func(state *persistedState, id string, now time.Time) (payloadResult, error)
func (c *Client) enqueue(ctx context.Context, kind recordType, build payloadBuilder) (Submission, error) {
if c.disabled {
return Submission{Reason: SkipDisabled}, nil
}
release, err := c.enterOperation(ctx, min(c.limits.LockWait, enqueueLockWait))
if err != nil {
if ignoreLockBusy(err) {
return Submission{Reason: SkipBusy}, nil
}
return Submission{}, err
}
defer release()
now := c.now()
state, _, err := c.loadState(now)
if err != nil {
return Submission{}, err
}
enabled, _, reason := c.effective(state)
if !enabled {
return Submission{Reason: reason}, nil
}
state.ensureIdentity(now)
id := NewUUID()
built, err := build(state, id, now)
if err != nil {
return Submission{}, err
}
data, err := marshalCanonical(built.Payload)
if err != nil {
return Submission{}, fmt.Errorf("telemetry: encode record: %w", err)
}
if kind == recordTypeEvent && len(data) > c.limits.MaxEventPayloadBytes {
return Submission{}, fmt.Errorf("%w: payload is %d bytes, limit %d", ErrInvalidEvent, len(data), c.limits.MaxEventPayloadBytes)
}
data, bodyBytes, truncated, err := c.fitRecord(kind, id, built.Payload, data, built.BodyBytes, built.Truncated)
if err != nil {
return Submission{}, err
}
if kind == recordTypeReport && bodyBytes == 0 {
return Submission{}, fmt.Errorf("%w: body is empty after record fitting", ErrInvalidReport)
}
if int64(len(data)) > c.limits.MaxQueuedBytes {
return Submission{Reason: SkipQueueFull}, nil
}
record := queuedRecord{
SchemaVersion: SchemaVersion,
ID: id,
Type: kind,
CreatedAt: now.UTC(),
BodyBytes: bodyBytes,
Payload: jsontext.Value(data),
}
if err := c.writeQueueRecord(record); err != nil {
return Submission{}, err
}
state.Queue = append(state.Queue, queueEntry{
ID: id,
Type: kind,
Sequence: state.NextSequence,
CreatedAt: now.UTC(),
PayloadBytes: int64(len(data)),
BodyBytes: bodyBytes,
})
state.NextSequence++
removed := c.pruneQueue(state, now)
if !queueContains(state.Queue, id) {
_ = os.Remove(c.queueRecordPath(id))
return Submission{Reason: SkipQueueFull}, nil
}
if err := c.saveState(state, now); err != nil {
_ = os.Remove(c.queueRecordPath(id))
return Submission{}, err
}
c.deleteQueuePayloads(removed)
c.pruneQueueOrphans(state)
return Submission{
Queued: true,
RecordID: id,
Truncated: truncated,
QueuedRecords: len(state.Queue),
}, nil
}
// fitRecord trims a report body until the complete serialized wire record fits
// the server's per-record bound. Event payloads are already bounded strictly
// enough that they must fit without adjustment.
func (c *Client) fitRecord(kind recordType, id string, payload any, data []byte, bodyBytes int64, truncated bool) ([]byte, int64, bool, error) {
encoded, err := marshalRecord(id, kind, data)
if err != nil {
return nil, 0, truncated, err
}
if len(encoded) <= c.limits.MaxRecordBytes {
return data, bodyBytes, truncated, nil
}
report, ok := payload.(reportPayload)
if !ok {
return nil, 0, truncated, fmt.Errorf("%w: serialized record is %d bytes, limit %d", ErrInvalidEvent, len(encoded), c.limits.MaxRecordBytes)
}
report, data, err = c.trimReportToRecord(id, kind, report)
if err != nil {
return nil, 0, truncated, err
}
return data, int64(len(report.Body)), true, nil
}
// trimReportToRecord keeps the longest body prefix whose serialized record
// fits the per-record bound. Escaping makes a body's serialized size differ
// from its length, so every candidate is measured on the encoded envelope.
// Serialized size never shrinks as the body grows, which turns the search into
// a bisection over body length.
func (c *Client) trimReportToRecord(id string, kind recordType, report reportPayload) (reportPayload, []byte, error) {
trimmed := report
trimmed.Body = ""
trimmedData, err := marshalCanonical(trimmed)
if err != nil {
return reportPayload{}, nil, fmt.Errorf("telemetry: encode report: %w", err)
}
encoded, err := marshalRecord(id, kind, trimmedData)
if err != nil {
return reportPayload{}, nil, err
}
if len(encoded) > c.limits.MaxRecordBytes {
return reportPayload{}, nil, fmt.Errorf("%w: record metadata exceeds %d bytes", ErrInvalidReport, c.limits.MaxRecordBytes)
}
var best reportPayload
var bestData []byte
for low, high := 1, len(report.Body); low <= high; {
middle := low + (high-low)/2
candidate := report
candidate.Body = truncateUTF8(report.Body, middle)
data, err := marshalCanonical(candidate)
if err != nil {
return reportPayload{}, nil, fmt.Errorf("telemetry: encode report: %w", err)
}
encoded, err := marshalRecord(id, kind, data)
if err != nil {
return reportPayload{}, nil, err
}
if len(encoded) <= c.limits.MaxRecordBytes {
best, bestData = candidate, data
low = middle + 1
} else {
high = middle - 1
}
}
if len(best.Body) == 0 {
return reportPayload{}, nil, fmt.Errorf("%w: record metadata leaves no room for a body", ErrInvalidReport)
}
return best, bestData, nil
}
// marshalRecord encodes the wire envelope around an already encoded payload.
func marshalRecord(id string, kind recordType, data []byte) ([]byte, error) {
encoded, err := marshalCanonical(wireRecord{ID: id, Type: kind, Payload: jsontext.Value(data)})
if err != nil {
return nil, fmt.Errorf("telemetry: encode record envelope: %w", err)
}
return encoded, nil
}
func (c *Client) writeQueueRecord(record queuedRecord) error {
data, err := marshalCanonical(record)
if err != nil {
return fmt.Errorf("telemetry: encode outbox record: %w", err)
}
if err := writeFileAtomic(c.queueRecordPath(record.ID), data); err != nil {
return fmt.Errorf("telemetry: write outbox record: %w", err)
}
return nil
}
func (c *Client) readQueueRecord(id string) (queuedRecord, error) {
data, err := os.ReadFile(c.queueRecordPath(id))
if err != nil {
return queuedRecord{}, err
}
var record queuedRecord
if err := json.Unmarshal(data, &record); err != nil {
return queuedRecord{}, fmt.Errorf("decode outbox record %s: %w", id, err)
}
if record.ID != id || (record.Type != recordTypeEvent && record.Type != recordTypeReport) || len(record.Payload) == 0 {
return queuedRecord{}, fmt.Errorf("outbox record %s is inconsistent", id)
}
return record, nil
}
func (c *Client) deleteQueuePayloads(entries []queueEntry) {
for _, entry := range entries {
if err := os.Remove(c.queueRecordPath(entry.ID)); err != nil && !errors.Is(err, os.ErrNotExist) {
c.debug("telemetry: remove outbox record failed", "id", entry.ID, "error", err)
}
}
}
func (c *Client) finishDisabledPurge(state *persistedState, now time.Time) error {
if err := os.RemoveAll(c.queueDirectory()); err != nil {
return fmt.Errorf("telemetry is off, but pending records could not be deleted: %w", err)
}
state.completePurge()
return c.saveState(state, now)
}
// pruneQueue removes stale records first, then oldest records until both queue
// bounds hold. The record just enqueued has the highest sequence and survives
// unless a single record exceeds MaxQueuedBytes (checked by the caller).
func (c *Client) pruneQueue(state *persistedState, now time.Time) []queueEntry {
cutoff := now.Add(-c.limits.MaxRecordAge)
removed := make([]queueEntry, 0)
kept := state.Queue[:0]
var totalBytes int64
for _, entry := range state.Queue {
if entry.CreatedAt.Before(cutoff) {
removed = append(removed, entry)
continue
}
kept = append(kept, entry)
totalBytes += entry.PayloadBytes
}
state.Queue = kept
for len(state.Queue) > 0 && (len(state.Queue) > c.limits.MaxQueuedRecords || totalBytes > c.limits.MaxQueuedBytes) {
entry := state.Queue[0]
state.Queue = state.Queue[1:]
totalBytes -= entry.PayloadBytes
removed = append(removed, entry)
}
state.QueueBytes = totalBytes
state.countDropped(removed)
return removed
}
func (c *Client) pruneQueueOrphans(state *persistedState) {
entries, err := os.ReadDir(c.queueDirectory())
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
c.debug("telemetry: read outbox directory failed", "error", err)
}
return
}
known := make(map[string]struct{}, len(state.Queue))
for _, entry := range state.Queue {
known[entry.ID] = struct{}{}
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
path := filepath.Join(c.queueDirectory(), name)
if strings.HasPrefix(name, ".") {
_ = os.Remove(path)
continue
}
if !strings.HasSuffix(name, ".json") {
continue
}
id := strings.TrimSuffix(name, ".json")
if _, exists := known[id]; exists {
continue
}
_ = os.Remove(path)
}
}
func queueContains(queue []queueEntry, id string) bool {
for _, entry := range queue {
if entry.ID == id {
return true
}
}
return false
}
func queueStatistics(state *persistedState) QueueStats {
stats := QueueStats{
Records: len(state.Queue),
PayloadBytes: state.QueueBytes,
}
for _, entry := range state.Queue {
switch entry.Type {
case recordTypeEvent:
stats.Events++
case recordTypeReport:
stats.Reports++
stats.ReportBodyBytes += entry.BodyBytes
}
if stats.Oldest.IsZero() || entry.CreatedAt.Before(stats.Oldest) {
stats.Oldest = entry.CreatedAt
}
}
return stats
}
package telemetry
import (
"fmt"
"strings"
"time"
)
func (c *Client) prepareReport(report Report, now time.Time) (preparedReport, bool, error) {
switch report.Kind {
case ReportFailure, ReportPanic, ReportAgentFeedback:
default:
return preparedReport{}, false, fmt.Errorf("%w: unknown report kind %q", ErrInvalidReport, report.Kind)
}
invocationID := ""
if strings.TrimSpace(report.InvocationID) != "" {
normalized, err := normalizeUUID(report.InvocationID)
if err != nil {
return preparedReport{}, false, fmt.Errorf("%w: invocation id: %v", ErrInvalidReport, err)
}
invocationID = normalized
}
body, truncated := sanitizeReportText(report.Body, c.limits.MaxReportBodyBytes)
if body == "" {
return preparedReport{}, false, fmt.Errorf("%w: body is empty after sanitization", ErrInvalidReport)
}
createdAt := report.CreatedAt
if createdAt.IsZero() {
createdAt = now
}
return preparedReport{
kind: report.Kind,
invocationID: invocationID,
agent: sanitizeReportIdentifier(report.Agent),
model: sanitizeReportIdentifier(report.Model),
body: body,
createdAt: createdAt.UTC(),
}, truncated, nil
}
func sanitizeReportIdentifier(raw string) string {
if len(raw) > 256 {
raw = raw[:256]
}
var builder strings.Builder
for _, character := range strings.TrimSpace(raw) {
switch {
case character >= 'a' && character <= 'z':
case character >= 'A' && character <= 'Z':
case character >= '0' && character <= '9':
case strings.ContainsRune("._-/:@", character):
default:
continue
}
builder.WriteRune(character)
if builder.Len() >= 64 {
break
}
}
return builder.String()
}
package telemetry
import (
"regexp"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
var (
bearerPattern = regexp.MustCompile(`(?i)\bbearer\s+[A-Za-z0-9._~+/\-=]+`)
authorizationPattern = regexp.MustCompile(`(?i)\b([a-z0-9_-]*authorization)\b\s*[:=]\s*[^\r\n]+`)
flagCredentialPattern = regexp.MustCompile(`(?i)(--?(?:token|password|passwd|secret|api[_-]?key|access[_-]?key))(\s+|=)(?:"(?:\\.|[^"\\])*"?|'(?:\\.|[^'\\])*'?|[^\s"'])+`)
credentialPattern = regexp.MustCompile(`(?i)\b([a-z0-9_]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|token|password|passwd|pwd|authorization|cookie|session[_-]?id)[a-z0-9_]*)\b["']?\s*[:=]\s*(?:"(?:\\.|[^"\\])*"?|'(?:\\.|[^'\\])*'?|[^\s,;"'])+`)
commonTokenPattern = regexp.MustCompile(`\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})\b`)
urlCredentialPattern = regexp.MustCompile(`(?i)\b([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@`)
privateKeyPattern = regexp.MustCompile(`(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)`)
ipv4Pattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
ipv6Candidate = regexp.MustCompile(`\b[0-9A-Fa-f:]{2,39}\b`)
unixPathPattern = regexp.MustCompile(`(^|[\s"'(\\[=:,<])(/(?:[A-Za-z0-9._-]+/)+[A-Za-z0-9._-]+)`)
homePathPattern = regexp.MustCompile(`~/[^\s"'<>]+`)
windowsPathPattern = regexp.MustCompile(`(?i)\b[A-Z]:\\[^\s"'<>]+`)
uncPathPattern = regexp.MustCompile(`\\\\[A-Za-z0-9._-]+\\[^\s"'<>]+`)
)
// sanitizeText converts free-form text into bounded, printable UTF-8. It
// normalizes line endings, removes ANSI escape sequences and control or
// formatting characters other than newline and tab, replaces invalid UTF-8,
// and appends a truncation marker when the byte bound is exceeded.
func sanitizeText(raw string, maxBytes int) (string, bool) {
if raw == "" || maxBytes <= 0 {
return "", false
}
truncated := false
if maxBytes <= (1<<30)/8 && len(raw) > maxBytes*8 {
raw = truncateUTF8(raw, maxBytes*8)
truncated = true
}
raw = strings.ReplaceAll(raw, "\r\n", "\n")
raw = strings.ReplaceAll(raw, "\r", "\n")
var builder strings.Builder
builder.Grow(min(len(raw), maxBytes))
for index := 0; index < len(raw); {
character, size := utf8.DecodeRuneInString(raw[index:])
if character == utf8.RuneError && size == 1 {
builder.WriteRune(utf8.RuneError)
index++
continue
}
index += size
switch {
case character == '\x1b':
index = skipEscapeSequence(raw, index)
case character == '\n' || character == '\t':
builder.WriteRune(character)
case character < 0x20 || character == 0x7f:
continue
case character >= 0x80 && character <= 0x9f:
continue
case unicode.Is(unicode.Cf, character):
continue
default:
builder.WriteRune(character)
}
}
text := strings.TrimSpace(builder.String())
if len(text) <= maxBytes {
return text, truncated
}
if maxBytes <= len(truncationMarker) {
return truncateUTF8(text, maxBytes), true
}
return truncateUTF8(text, maxBytes-len(truncationMarker)) + truncationMarker, true
}
func skipEscapeSequence(raw string, index int) int {
if index >= len(raw) {
return index
}
switch raw[index] {
case '[':
index++
for index < len(raw) {
character := raw[index]
index++
if character >= 0x40 && character <= 0x7e {
break
}
}
return index
case ']', 'P', '^', '_':
index++
for index < len(raw) {
if raw[index] == 0x07 {
return index + 1
}
if raw[index] == 0x1b && index+1 < len(raw) && raw[index+1] == '\\' {
return index + 2
}
index++
}
return index
default:
return index + 1
}
}
func truncateUTF8(value string, maxBytes int) string {
if maxBytes <= 0 {
return ""
}
if len(value) <= maxBytes {
return value
}
cut := value[:maxBytes]
for len(cut) > 0 && !utf8.ValidString(cut) {
cut = cut[:len(cut)-1]
}
return cut
}
func sanitizeToken(raw string, maxBytes int) string {
var builder strings.Builder
for _, character := range raw {
switch {
case character >= 'a' && character <= 'z':
case character >= 'A' && character <= 'Z':
case character >= '0' && character <= '9':
case strings.ContainsRune("-_.:", character):
default:
continue
}
builder.WriteRune(character)
if builder.Len() >= maxBytes {
break
}
}
return builder.String()
}
func validChannel(channel string) bool {
if channel == "" || len(channel) > 32 {
return false
}
for index, character := range channel {
valid := character >= 'a' && character <= 'z' ||
character >= '0' && character <= '9'
if index > 0 {
valid = valid || character == '-' || character == '_' || character == '.'
}
if !valid {
return false
}
}
return true
}
func normalizeCommand(raw string) (string, error) {
if len(raw) > 512 {
return "", ErrInvalidEvent
}
fields := strings.Fields(raw)
if len(fields) == 0 || len(fields) > 4 {
return "", ErrInvalidEvent
}
var builder strings.Builder
for index, field := range fields {
if len(field) > 32 || !validCommandToken(field) {
return "", ErrInvalidEvent
}
if index > 0 {
builder.WriteByte(' ')
}
builder.WriteString(field)
}
command := builder.String()
if len(command) > 96 {
return "", ErrInvalidEvent
}
return command, nil
}
func validCommandToken(token string) bool {
for index := 0; index < len(token); index++ {
character := token[index]
valid := character >= 'a' && character <= 'z' ||
character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9'
if index > 0 {
valid = valid || character == '-' || character == '_' || character == ':'
}
if !valid {
return false
}
}
return true
}
func validCode(raw string, maxBytes int) bool {
if raw == "" || len(raw) > maxBytes {
return false
}
for index := 0; index < len(raw); index++ {
character := raw[index]
valid := character >= 'a' && character <= 'z' ||
character >= 'A' && character <= 'Z' ||
character >= '0' && character <= '9'
if index > 0 {
valid = valid || character == '-' || character == '_' || character == '.' || character == ':'
}
if !valid {
return false
}
}
return true
}
func sanitizeErrorText(err error) string {
if err == nil {
return ""
}
text, _ := sanitizeText(err.Error(), 256)
return strings.Join(strings.Fields(text), " ")
}
// sanitizeReportText applies the printable-text filter and then redacts common
// secrets, network addresses, and absolute paths. Redaction is defense in
// depth, not anonymization: callers must still keep raw transcripts and file
// attachments out of reports.
func sanitizeReportText(raw string, maxBytes int) (string, bool) {
if raw == "" || maxBytes <= 0 {
return "", false
}
inputLimit := maxBytes * 8
truncated := len(raw) > inputLimit
if truncated {
raw = truncateUTF8(raw, inputLimit)
}
text, inputClipped := sanitizeText(raw, inputLimit)
truncated = truncated || inputClipped
if text == "" {
return "", truncated
}
text = redactReportText(text)
bounded, clipped := boundWithMarker(text, maxBytes)
return bounded, truncated || clipped
}
func redactReportText(text string) string {
text = privateKeyPattern.ReplaceAllString(text, "[redacted-private-key]")
text = bearerPattern.ReplaceAllString(text, "Bearer [redacted]")
text = authorizationPattern.ReplaceAllString(text, "${1}=[redacted]")
text = flagCredentialPattern.ReplaceAllString(text, "${1}${2}[redacted]")
text = credentialPattern.ReplaceAllString(text, "${1}=[redacted]")
text = commonTokenPattern.ReplaceAllString(text, "[redacted-token]")
text = urlCredentialPattern.ReplaceAllString(text, "${1}[redacted]@")
text = redactIPv4(text)
text = redactIPv6(text)
text = windowsPathPattern.ReplaceAllString(text, "[redacted-path]")
text = uncPathPattern.ReplaceAllString(text, "[redacted-path]")
text = homePathPattern.ReplaceAllString(text, "[redacted-path]")
text = unixPathPattern.ReplaceAllString(text, "${1}[redacted-path]")
return text
}
func redactIPv4(text string) string {
return ipv4Pattern.ReplaceAllStringFunc(text, func(candidate string) string {
for _, part := range strings.Split(candidate, ".") {
value, err := strconv.Atoi(part)
if err != nil || value > 255 {
return candidate
}
}
return "[redacted-ip]"
})
}
func redactIPv6(text string) string {
return ipv6Candidate.ReplaceAllStringFunc(text, func(candidate string) string {
if !strings.Contains(candidate, ":") {
return candidate
}
if strings.Contains(candidate, "::") {
return "[redacted-ip]"
}
groups := 0
for _, group := range strings.Split(candidate, ":") {
if group != "" {
groups++
}
}
if groups >= 4 && strings.Count(candidate, ":") >= 3 {
return "[redacted-ip]"
}
return candidate
})
}
func boundWithMarker(text string, maxBytes int) (string, bool) {
if len(text) <= maxBytes {
return text, false
}
text = strings.TrimSuffix(text, truncationMarker)
if maxBytes <= len(truncationMarker) {
return truncateUTF8(text, maxBytes), true
}
return truncateUTF8(text, maxBytes-len(truncationMarker)) + truncationMarker, true
}
package telemetry
import (
"cmp"
"encoding/json/v2"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"time"
)
var errCorruptState = errors.New("telemetry: state is corrupt; run `lucy telemetry off` to reset it")
type acceptedState struct {
Events int64 `json:"events"`
Reports int64 `json:"reports"`
EventPayloadBytes int64 `json:"event_payload_bytes"`
ReportBodyBytes int64 `json:"report_body_bytes"`
}
type rejectedState struct {
Records int64 `json:"records"`
PayloadBytes int64 `json:"payload_bytes"`
}
type droppedState struct {
Records int64 `json:"records"`
Bytes int64 `json:"bytes"`
}
type transportState struct {
Requests int64 `json:"requests"`
Succeeded int64 `json:"succeeded"`
RejectedRequests int64 `json:"rejected_requests"`
Uncertain int64 `json:"uncertain"`
RequestBodyBytes int64 `json:"request_body_bytes"`
LastRequestBodyBytes int64 `json:"last_request_body_bytes"`
}
type queueEntry struct {
ID string `json:"id"`
Type recordType `json:"type"`
Sequence int64 `json:"sequence"`
CreatedAt time.Time `json:"created_at"`
PayloadBytes int64 `json:"payload_bytes"`
BodyBytes int64 `json:"body_bytes,omitempty"`
}
// persistedState is the single source of truth for identity, policy, counters,
// and the queue index. Queue payloads live in queue/<id>.json so accepting a
// record and counting it happen in one atomic state replacement.
type persistedState struct {
SchemaVersion int `json:"schema_version"`
InstallationID string `json:"installation_id,omitempty"`
IdentityCreatedAt time.Time `json:"identity_created_at,omitzero"`
Policy CollectionPolicy `json:"policy"`
PeriodStart time.Time `json:"period_start,omitzero"`
Accepted acceptedState `json:"accepted"`
Rejected rejectedState `json:"rejected"`
Dropped droppedState `json:"dropped"`
Transport transportState `json:"transport"`
PurgePending bool `json:"purge_pending,omitzero"`
Queue []queueEntry `json:"queue,omitempty"`
QueueBytes int64 `json:"queue_bytes"`
NextSequence int64 `json:"next_sequence"`
AcceptedIDs []string `json:"accepted_ids,omitempty"`
CooldownUntil time.Time `json:"cooldown_until,omitzero"`
LastError string `json:"last_error,omitempty"`
LastUploadAt time.Time `json:"last_upload_at,omitzero"`
UpdatedAt time.Time `json:"updated_at,omitzero"`
}
func newState(now time.Time) *persistedState {
return &persistedState{
SchemaVersion: SchemaVersion,
Policy: CollectionDefault,
PeriodStart: now.UTC(),
NextSequence: 1,
}
}
func (c *Client) statePath() string {
return filepath.Join(c.directory, "state.json")
}
func (c *Client) lockPath() string {
return filepath.Join(c.directory, "state.lock")
}
func (c *Client) queueDirectory() string {
return filepath.Join(c.directory, "queue")
}
func (c *Client) queueRecordPath(id string) string {
return filepath.Join(c.queueDirectory(), id+".json")
}
func (c *Client) ensureDirectories() error {
for _, directory := range []string{c.directory, c.queueDirectory()} {
if err := os.MkdirAll(directory, 0o700); err != nil {
return fmt.Errorf("telemetry: create state directory: %w", err)
}
if err := os.Chmod(directory, 0o700); err != nil {
return fmt.Errorf("telemetry: secure state directory: %w", err)
}
}
return nil
}
func (c *Client) loadState(now time.Time) (*persistedState, bool, error) {
data, err := os.ReadFile(c.statePath())
if errors.Is(err, os.ErrNotExist) {
return newState(now), false, nil
}
if err != nil {
return nil, false, fmt.Errorf("telemetry: read state: %w", err)
}
_ = os.Chmod(c.statePath(), 0o600)
var state persistedState
if err := json.Unmarshal(data, &state); err != nil {
return nil, true, fmt.Errorf("%w: %v", errCorruptState, err)
}
if state.SchemaVersion < 1 {
return nil, true, errCorruptState
}
switch state.Policy {
case CollectionDefault, CollectionOptIn, CollectionOptOut:
default:
return nil, true, errCorruptState
}
if state.SchemaVersion > SchemaVersion {
return nil, true, fmt.Errorf("telemetry: state schema %d is newer than supported schema %d", state.SchemaVersion, SchemaVersion)
}
if state.InstallationID != "" {
if _, err := normalizeUUID(state.InstallationID); err != nil {
return nil, true, errCorruptState
}
}
state.normalize(now)
if state.PurgePending {
if state.Policy != CollectionOptOut {
return nil, true, errCorruptState
}
if err := c.finishDisabledPurge(&state, now); err != nil {
return nil, true, err
}
}
return &state, true, nil
}
func (c *Client) saveState(state *persistedState, now time.Time) error {
state.SchemaVersion = SchemaVersion
state.UpdatedAt = now.UTC()
data, err := marshalCanonical(state)
if err != nil {
return fmt.Errorf("telemetry: encode state: %w", err)
}
if err := writeFileAtomic(c.statePath(), data); err != nil {
return fmt.Errorf("telemetry: write state: %w", err)
}
return nil
}
func (state *persistedState) normalize(now time.Time) {
if state.InstallationID != "" {
installationID, err := normalizeUUID(state.InstallationID)
if err != nil {
state.InstallationID = ""
state.IdentityCreatedAt = time.Time{}
} else {
state.InstallationID = installationID
}
}
if state.PeriodStart.IsZero() {
state.PeriodStart = now.UTC()
}
if state.InstallationID != "" && state.IdentityCreatedAt.IsZero() {
state.IdentityCreatedAt = state.PeriodStart
}
if state.InstallationID == "" {
state.IdentityCreatedAt = time.Time{}
}
if state.NextSequence < 1 {
state.NextSequence = 1
}
kept := state.Queue[:0]
var queueBytes int64
var maximumSequence int64
for _, entry := range state.Queue {
entryID, err := normalizeUUID(entry.ID)
if err != nil || entry.PayloadBytes <= 0 {
state.Dropped.Records++
continue
}
if entry.Type != recordTypeEvent && entry.Type != recordTypeReport {
state.Dropped.Records++
continue
}
if entry.Sequence < 1 {
state.Dropped.Records++
continue
}
entry.ID = entryID
if entry.CreatedAt.IsZero() {
entry.CreatedAt = now.UTC()
}
kept = append(kept, entry)
queueBytes += entry.PayloadBytes
maximumSequence = max(maximumSequence, entry.Sequence)
}
state.Queue = kept
state.QueueBytes = queueBytes
if state.NextSequence <= maximumSequence {
state.NextSequence = maximumSequence + 1
}
slices.SortFunc(state.Queue, func(left, right queueEntry) int {
return cmp.Compare(left.Sequence, right.Sequence)
})
state.normalizeAcceptedIDs()
}
func (state *persistedState) normalizeAcceptedIDs() {
if len(state.AcceptedIDs) == 0 {
state.AcceptedIDs = nil
return
}
seen := make(map[string]struct{}, len(state.AcceptedIDs))
kept := make([]string, 0, min(len(state.AcceptedIDs), defaultMaxAcceptedIDHistory))
start := max(0, len(state.AcceptedIDs)-defaultMaxAcceptedIDHistory)
for _, raw := range state.AcceptedIDs[start:] {
id, err := normalizeUUID(raw)
if err != nil {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
kept = append(kept, id)
}
state.AcceptedIDs = kept
}
func (state *persistedState) rememberAccepted(id string) bool {
for _, accepted := range state.AcceptedIDs {
if accepted == id {
return false
}
}
state.AcceptedIDs = append(state.AcceptedIDs, id)
if len(state.AcceptedIDs) > defaultMaxAcceptedIDHistory {
state.AcceptedIDs = state.AcceptedIDs[len(state.AcceptedIDs)-defaultMaxAcceptedIDHistory:]
}
return true
}
// countDropped adds records discarded locally without a server decision.
func (state *persistedState) countDropped(entries []queueEntry) {
state.Dropped.Records += int64(len(entries))
for _, entry := range entries {
state.Dropped.Bytes += entry.PayloadBytes
}
}
// countRejected adds one record the server permanently rejected.
func (state *persistedState) countRejected(payloadBytes int64) {
state.Rejected.Records++
state.Rejected.PayloadBytes += payloadBytes
}
func (state *persistedState) ensureIdentity(now time.Time) {
if state.InstallationID != "" {
return
}
state.InstallationID = NewUUID()
state.IdentityCreatedAt = now.UTC()
if state.Accepted == (acceptedState{}) && state.Rejected == (rejectedState{}) && state.Dropped == (droppedState{}) {
state.PeriodStart = now.UTC()
}
}
// clearBackoff forgets a pending retry deadline and its diagnostic. Explicit
// policy changes and identity resets supersede an earlier transport failure.
func (state *persistedState) clearBackoff() {
state.CooldownUntil = time.Time{}
state.LastError = ""
}
// deferRetry records a failed attempt and blocks uploads until the cooldown
// elapses. A failure that must not defer retries records LastError directly.
func (state *persistedState) deferRetry(now time.Time, cooldown time.Duration, message string) {
state.CooldownUntil = now.Add(cooldown)
state.LastError = message
}
// optIn records an explicit opt-in and creates an identity when none exists.
func (state *persistedState) optIn(now time.Time) {
state.Policy = CollectionOptIn
state.ensureIdentity(now)
state.clearBackoff()
}
// optOut records an explicit opt-out. PurgePending makes the queue purge
// resumable; the queue index is cleared only after its payloads are gone.
func (state *persistedState) optOut() {
state.Policy = CollectionOptOut
state.PurgePending = true
state.clearBackoff()
}
// completePurge clears the queue index and the purge marker after the payload
// directory has been removed.
func (state *persistedState) completePurge() {
state.Queue = nil
state.QueueBytes = 0
state.PurgePending = false
}
// resetIdentity rotates the installation UUID and restarts the statistics
// window. Queued records keep their IDs and payloads.
func (state *persistedState) resetIdentity(now time.Time) {
state.InstallationID = NewUUID()
state.IdentityCreatedAt = now.UTC()
state.PeriodStart = now.UTC()
state.Accepted = acceptedState{}
state.Rejected = rejectedState{}
state.Dropped = droppedState{}
state.Transport = transportState{}
state.AcceptedIDs = nil
state.clearBackoff()
state.LastUploadAt = time.Time{}
}
func marshalCanonical(value any) ([]byte, error) {
return json.Marshal(value, json.Deterministic(true))
}
func writeFileAtomic(path string, data []byte) error {
directory := filepath.Dir(path)
if err := os.MkdirAll(directory, 0o700); err != nil {
return err
}
file, err := os.CreateTemp(directory, "."+filepath.Base(path)+"-*")
if err != nil {
return err
}
temporaryPath := file.Name()
defer func() {
if temporaryPath != "" {
_ = os.Remove(temporaryPath)
}
}()
if err := file.Chmod(0o600); err != nil {
_ = file.Close()
return err
}
if _, err := file.Write(data); err != nil {
_ = file.Close()
return err
}
if err := file.Sync(); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
if err := os.Rename(temporaryPath, path); err != nil {
return err
}
temporaryPath = ""
syncDirectory(directory)
return nil
}
func syncDirectory(path string) {
directory, err := os.Open(path)
if err != nil {
return
}
_ = directory.Sync()
_ = directory.Close()
}
package telemetry
import (
"errors"
"slices"
"time"
)
// Schema and protocol values that appear on the wire.
const (
payloadTypeEvent = "event"
payloadTypeReport = "report"
)
// ErrInvalidEvent reports a caller-provided event that does not satisfy the
// telemetry schema. It is a programming error, not a telemetry outage.
var ErrInvalidEvent = errors.New("telemetry: invalid event")
// ErrInvalidReport reports a caller-provided report that does not satisfy the
// telemetry schema. It is a programming error, not a telemetry outage.
var ErrInvalidReport = errors.New("telemetry: invalid report")
// CollectionPolicy is the persisted reason telemetry is enabled or disabled.
// The wire value of a record is default or opt_in; opt_out records are never
// collected.
type CollectionPolicy string
const (
// CollectionDefault follows the channel default (on for alpha and beta).
CollectionDefault CollectionPolicy = "default"
// CollectionOptIn is an explicit user enablement.
CollectionOptIn CollectionPolicy = "opt_in"
// CollectionOptOut is an explicit user disablement.
CollectionOptOut CollectionPolicy = "opt_out"
)
// SkipReason explains why a record was not queued or an upload did not run.
type SkipReason string
const (
SkipNone SkipReason = ""
SkipDisabled SkipReason = "disabled"
SkipEnvironment SkipReason = "environment_disabled"
SkipEndpoint SkipReason = "endpoint_unconfigured"
SkipCooldown SkipReason = "cooldown"
SkipBusy SkipReason = "busy"
SkipEmpty SkipReason = "empty"
SkipQueueFull SkipReason = "queue_full"
)
// Outcome is the result of one Lucy invocation.
type Outcome string
const (
OutcomeSuccess Outcome = "success"
OutcomeFailure Outcome = "failure"
OutcomeCanceled Outcome = "canceled"
)
// ContextStatus describes whether a typed context snapshot was available.
type ContextStatus string
const (
ContextPresent ContextStatus = "present"
ContextAbsent ContextStatus = "absent"
ContextFailed ContextStatus = "failed"
)
// ContextKind names a typed context snapshot. The wire type is a bounded
// string; these constants name the kinds Lucy currently emits.
type ContextKind string
const (
ContextServer ContextKind = "server"
ContextRuntime ContextKind = "runtime"
ContextManifest ContextKind = "manifest"
ContextLockfile ContextKind = "lockfile"
ContextPackages ContextKind = "packages"
ContextWorkspace ContextKind = "workspace"
)
// Context is an optional typed snapshot attached to an event. Data must be
// JSON-marshalable and fit in 8 KiB. Data is required when Status is present
// and must be nil when Status is absent. Failed contexts may include data.
type Context struct {
Kind ContextKind
Status ContextStatus
Data any
}
// Event describes one non-sensitive invocation observation. Command is the
// trusted command path only (for example "java list"); arguments and their
// values are never accepted.
type Event struct {
InvocationID string
Command string
Duration time.Duration
Outcome Outcome
ErrorCode string
OccurredAt time.Time
Contexts []Context
}
// ReportKind classifies an explicitly submitted report.
type ReportKind string
const (
ReportFailure ReportKind = "failure"
ReportPanic ReportKind = "panic"
ReportAgentFeedback ReportKind = "agent_feedback"
)
// Report is a free-form, explicitly submitted diagnostic. Body is sanitized,
// bounded UTF-8 text with common secrets, IP addresses, and absolute paths
// redacted as defense in depth, not anonymization. Callers must not place raw
// transcripts or file attachments in Body. Agent and Model are optional
// bounded identifiers.
type Report struct {
Kind ReportKind
InvocationID string
Agent string
Model string
Body string
CreatedAt time.Time
}
// Submission reports whether a record was durably placed in the local queue.
// Queued means the record will be retried later; it does not mean the server
// has accepted it. When Queued is false, Reason explains why.
type Submission struct {
Queued bool `json:"queued"`
RecordID string `json:"record_id,omitempty"`
Reason SkipReason `json:"reason,omitempty"`
Truncated bool `json:"truncated,omitempty"`
QueuedRecords int `json:"queued_records"`
}
// FlushResult describes one bounded upload run. AcceptedIDs and RejectedIDs
// contain record IDs the server permanently accepted or rejected in this run,
// so a caller can check the fate of a specific report's Submission.RecordID.
// Details are diagnostic text and never affect CLI outcomes.
type FlushResult struct {
Attempted int `json:"attempted"`
Accepted int `json:"accepted"`
Rejected int `json:"rejected"`
Remaining int `json:"remaining"`
Skipped bool `json:"skipped"`
Reason SkipReason `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
AcceptedIDs []string `json:"accepted_ids,omitempty"`
RejectedIDs []string `json:"rejected_ids,omitempty"`
}
// WasAccepted reports whether the server durably accepted id during this run.
func (result FlushResult) WasAccepted(id string) bool {
return slices.Contains(result.AcceptedIDs, id)
}
// WasRejected reports whether the server permanently rejected id during this
// run. Rejected records are removed from the queue and are not retried.
func (result FlushResult) WasRejected(id string) bool {
return slices.Contains(result.RejectedIDs, id)
}
// AcceptedStats counts records the server durably accepted in the current
// statistics window. Bytes are logical payload and sanitized body lengths, not
// HTTP, TLS, or framing overhead.
type AcceptedStats struct {
Events int64 `json:"events"`
Reports int64 `json:"reports"`
Records int64 `json:"records"`
EventPayloadBytes int64 `json:"event_payload_bytes"`
ReportBodyBytes int64 `json:"report_body_bytes"`
}
// RejectedStats counts records the server permanently rejected in the current
// statistics window. Bytes are logical serialized payload bytes.
type RejectedStats struct {
Records int64 `json:"records"`
PayloadBytes int64 `json:"payload_bytes"`
}
// DroppedStats counts records discarded locally because they were stale,
// corrupt, or exceeded the bounded queue. They were never accepted remotely.
type DroppedStats struct {
Records int64 `json:"records"`
Bytes int64 `json:"bytes"`
}
// TransportStats counts locally persisted upload attempts. Requests, outcome
// counts, and RequestBodyBytes cover every HTTP attempt, including retries.
// RequestBodyBytes is the exact serialized JSON body handed to the HTTP
// transport; it excludes HTTP headers, framing, TLS, and other wire overhead
// and must never be presented as network usage. Uncertain counts attempts
// whose acceptance is unknown (transport error, timeout, or an unparseable
// success response). An attempt interrupted by process termination may be
// missing from these counters.
type TransportStats struct {
Requests int64 `json:"requests"`
Succeeded int64 `json:"succeeded"`
RejectedRequests int64 `json:"rejected_requests"`
Uncertain int64 `json:"uncertain"`
RequestBodyBytes int64 `json:"request_body_bytes"`
LastRequestBodyBytes int64 `json:"last_request_body_bytes"`
}
// QueueStats describes locally queued records that have not been accepted or
// rejected remotely. PayloadBytes and ReportBodyBytes are logical lengths.
type QueueStats struct {
Records int `json:"records"`
Events int `json:"events"`
Reports int `json:"reports"`
PayloadBytes int64 `json:"payload_bytes"`
ReportBodyBytes int64 `json:"report_body_bytes"`
Oldest time.Time `json:"oldest,omitzero"`
}
// Stats is a transparent snapshot of telemetry state. Enabled reports whether
// collection is allowed; EndpointConfigured separately reports whether uploads
// can run. PeriodStart begins at identity creation or the last ResetIdentity;
// pending records from before a reset are accepted later and count in the new
// window.
type Stats struct {
Enabled bool `json:"enabled"`
Reason SkipReason `json:"reason,omitempty"`
Policy CollectionPolicy `json:"policy"`
Endpoint string `json:"endpoint,omitempty"`
EndpointConfigured bool `json:"endpoint_configured"`
InstallationID string `json:"installation_id,omitempty"`
PeriodStart time.Time `json:"period_start,omitzero"`
Accepted AcceptedStats `json:"accepted"`
Rejected RejectedStats `json:"rejected"`
Dropped DroppedStats `json:"dropped"`
Transport TransportStats `json:"transport"`
Queue QueueStats `json:"queue"`
CooldownUntil time.Time `json:"cooldown_until,omitzero"`
LastError string `json:"last_error,omitempty"`
StateDirectory string `json:"state_directory,omitempty"`
}
// Identity is the result of ResetIdentity.
type Identity struct {
InstallationID string `json:"installation_id"`
CreatedAt time.Time `json:"created_at"`
Policy CollectionPolicy `json:"policy"`
}
package telemetry
import (
"context"
"errors"
"fmt"
"time"
)
type preparedRecord struct {
entry queueEntry
record queuedRecord
wire wireRecord
}
// Flush performs one bounded upload run. It never blocks on another process's
// upload: a busy state returns Skipped/busy and leaves the queue intact.
// Network, health, and protocol failures are reported through FlushResult and
// never as errors. Errors are limited to local state I/O.
func (c *Client) Flush(ctx context.Context) (FlushResult, error) {
if ctx == nil {
ctx = context.Background()
}
result := FlushResult{}
if c.disabled {
result.Skipped = true
result.Reason = SkipDisabled
return result, nil
}
if environmentDisabled() {
result.Skipped = true
result.Reason = SkipEnvironment
return result, nil
}
if c.endpoint == "" {
result.Skipped = true
result.Reason = SkipEndpoint
return result, nil
}
release, err := c.enterOperation(ctx, 0)
if err != nil {
if ignoreLockBusy(err) {
result.Skipped = true
result.Reason = SkipBusy
return result, nil
}
return result, err
}
defer release()
flushContext, cancel := context.WithTimeout(ctx, c.limits.FlushTimeout)
defer cancel()
now := c.now()
state, _, err := c.loadState(now)
if err != nil {
return result, err
}
enabled, _, reason := c.effective(state)
if !enabled {
result.Skipped = true
result.Reason = reason
return result, nil
}
removed := c.pruneQueue(state, now)
if len(state.Queue) == 0 {
if len(removed) > 0 {
if err := c.saveState(state, now); err != nil {
return result, err
}
c.deleteQueuePayloads(removed)
}
result.Skipped = true
result.Reason = SkipEmpty
return result, nil
}
if !state.CooldownUntil.IsZero() && now.Before(state.CooldownUntil) {
if err := c.saveState(state, now); err != nil {
return result, err
}
c.deleteQueuePayloads(removed)
result.Skipped = true
result.Reason = SkipCooldown
result.Detail = "retry after " + state.CooldownUntil.UTC().Format(time.RFC3339)
result.Remaining = len(state.Queue)
return result, nil
}
if ctx.Err() != nil {
return result, nil
}
if err := c.checkHealth(flushContext); err != nil {
if errors.Is(flushContext.Err(), context.Canceled) {
return result, nil
}
state.deferRetry(now, c.limits.Cooldown, "health check: "+sanitizeErrorText(err))
if saveErr := c.saveState(state, now); saveErr != nil {
return result, saveErr
}
c.deleteQueuePayloads(removed)
c.pruneQueueOrphans(state)
result.Skipped = true
result.Reason = SkipCooldown
result.Detail = state.LastError
result.Remaining = len(state.Queue)
return result, nil
}
state.CooldownUntil = time.Time{}
skipped := make(map[string]struct{})
for range c.limits.MaxBatches {
if flushContext.Err() != nil {
break
}
prepared, missing := c.prepareBatch(state, skipped)
if len(missing) > 0 {
c.dropEntries(state, missing)
}
if len(prepared) == 0 {
if err := c.saveState(state, now); err != nil {
return result, err
}
break
}
records := make([]wireRecord, len(prepared))
for index := range prepared {
records[index] = prepared[index].wire
}
upload, uploadErr := c.uploadRecords(flushContext, records, state)
result.Attempted += len(records)
if uploadErr != nil {
if failure, isHTTP := errors.AsType[*httpError](uploadErr); isHTTP && failure.batchInvalid {
rejectedEntries := c.applyRejectedBatch(state, prepared)
result.Rejected += len(rejectedEntries)
result.RejectedIDs = append(result.RejectedIDs, entryIDs(rejectedEntries)...)
state.LastError = sanitizeErrorText(uploadErr)
if err := c.saveState(state, now); err != nil {
return result, err
}
c.deleteQueuePayloads(rejectedEntries)
continue
}
message := sanitizeErrorText(uploadErr)
if errors.Is(flushContext.Err(), context.Canceled) {
state.LastError = message
} else {
state.deferRetry(now, c.limits.Cooldown, message)
}
if err := c.saveState(state, now); err != nil {
return result, err
}
result.Remaining = len(state.Queue)
result.Detail = state.LastError
return result, nil
}
acceptedEntries, rejectedEntries := c.applyUploadResult(state, prepared, upload, skipped, now)
result.Accepted += len(acceptedEntries)
result.Rejected += len(rejectedEntries)
result.AcceptedIDs = append(result.AcceptedIDs, entryIDs(acceptedEntries)...)
result.RejectedIDs = append(result.RejectedIDs, entryIDs(rejectedEntries)...)
state.LastError = ""
if err := c.saveState(state, now); err != nil {
return result, err
}
c.deleteQueuePayloads(acceptedEntries)
c.deleteQueuePayloads(rejectedEntries)
classified := len(acceptedEntries) + len(rejectedEntries)
if classified == 0 {
result.Detail = "server did not acknowledge any record in the batch"
break
}
if classified < len(prepared) {
result.Detail = fmt.Sprintf("server classified %d of %d records in the batch", classified, len(prepared))
break
}
}
if err := c.saveState(state, now); err != nil {
return result, err
}
result.Remaining = len(state.Queue)
if result.Attempted == 0 && len(state.Queue) == 0 {
result.Skipped = true
result.Reason = SkipEmpty
}
c.pruneQueueOrphans(state)
return result, nil
}
// prepareBatch loads the oldest records that fit both server bounds. Records
// whose outbox files are missing or corrupt are returned for local dropping so
// they cannot block healthy records.
func (c *Client) prepareBatch(state *persistedState, skipped map[string]struct{}) ([]preparedRecord, []queueEntry) {
prepared := make([]preparedRecord, 0, min(len(state.Queue), c.limits.MaxRecordsPerRequest))
missing := make([]queueEntry, 0)
estimated := int64(len(`{"records":[]}`))
for _, entry := range state.Queue {
if _, skip := skipped[entry.ID]; skip {
continue
}
if len(prepared) >= c.limits.MaxRecordsPerRequest {
break
}
record, err := c.readQueueRecord(entry.ID)
if err != nil {
c.debug("telemetry: dropping unreadable outbox record", "id", entry.ID, "error", err)
missing = append(missing, entry)
continue
}
wire := wireRecord{ID: entry.ID, Type: entry.Type, Payload: record.Payload}
encoded, err := marshalCanonical(wire)
if err != nil || len(encoded) > c.limits.MaxRecordBytes {
missing = append(missing, entry)
continue
}
additional := int64(len(encoded)) + 1
if len(prepared) > 0 && estimated+additional > int64(c.limits.MaxRequestBytes) {
break
}
estimated += additional
prepared = append(prepared, preparedRecord{entry: entry, record: record, wire: wire})
}
return prepared, missing
}
func (c *Client) dropEntries(state *persistedState, entries []queueEntry) {
if len(entries) == 0 {
return
}
drop := make(map[string]struct{}, len(entries))
for _, entry := range entries {
drop[entry.ID] = struct{}{}
}
c.removeQueueIDs(state, drop)
state.countDropped(entries)
}
func (c *Client) applyUploadResult(state *persistedState, prepared []preparedRecord, upload uploadResult, skipped map[string]struct{}, now time.Time) ([]queueEntry, []queueEntry) {
acceptedIDs := make(map[string]struct{}, len(upload.accepted))
for _, id := range upload.accepted {
acceptedIDs[id] = struct{}{}
}
rejectedIDs := make(map[string]struct{}, len(upload.rejected))
for _, rejection := range upload.rejected {
rejectedIDs[rejection.ID] = struct{}{}
}
accepted := make([]queueEntry, 0)
rejected := make([]queueEntry, 0)
remove := make(map[string]struct{})
for _, item := range prepared {
if _, ok := acceptedIDs[item.entry.ID]; ok {
delete(rejectedIDs, item.entry.ID)
if _, duplicate := remove[item.entry.ID]; duplicate {
continue
}
remove[item.entry.ID] = struct{}{}
accepted = append(accepted, item.entry)
if !state.rememberAccepted(item.entry.ID) {
continue
}
switch item.entry.Type {
case recordTypeEvent:
state.Accepted.Events++
state.Accepted.EventPayloadBytes += int64(len(item.record.Payload))
case recordTypeReport:
state.Accepted.Reports++
state.Accepted.ReportBodyBytes += item.entry.BodyBytes
}
continue
}
if _, ok := rejectedIDs[item.entry.ID]; ok {
remove[item.entry.ID] = struct{}{}
rejected = append(rejected, item.entry)
state.countRejected(int64(len(item.record.Payload)))
continue
}
skipped[item.entry.ID] = struct{}{}
}
c.removeQueueIDs(state, remove)
state.LastUploadAt = now.UTC()
return accepted, rejected
}
func (c *Client) applyRejectedBatch(state *persistedState, prepared []preparedRecord) []queueEntry {
remove := make(map[string]struct{}, len(prepared))
rejected := make([]queueEntry, 0, len(prepared))
for _, item := range prepared {
remove[item.entry.ID] = struct{}{}
rejected = append(rejected, item.entry)
state.countRejected(int64(len(item.record.Payload)))
}
c.removeQueueIDs(state, remove)
return rejected
}
func (c *Client) removeQueueIDs(state *persistedState, remove map[string]struct{}) {
if len(remove) == 0 {
return
}
kept := state.Queue[:0]
var queueBytes int64
for _, entry := range state.Queue {
if _, drop := remove[entry.ID]; drop {
continue
}
kept = append(kept, entry)
queueBytes += entry.PayloadBytes
}
state.Queue = kept
state.QueueBytes = queueBytes
}
func entryIDs(entries []queueEntry) []string {
ids := make([]string, 0, len(entries))
for _, entry := range entries {
ids = append(ids, entry.ID)
}
return ids
}
package telemetry
import (
"fmt"
"strings"
"uuid"
)
// NewUUID returns a random RFC 9562 version 4 UUID from the standard library.
func NewUUID() string {
return uuid.NewV4().String()
}
func normalizeUUID(raw string) (string, error) {
parsed, err := uuid.Parse(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("parse uuid: %w", err)
}
return parsed.String(), nil
}
package telemetry
import (
"bytes"
"context"
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const maxResponseBytes = 1 << 20
type wireRecord struct {
ID string `json:"id"`
Type recordType `json:"type"`
Payload jsontext.Value `json:"payload"`
}
type wireRequest struct {
Records []wireRecord `json:"records"`
}
type wireRejection struct {
ID string `json:"id"`
Reason string `json:"reason"`
}
type wireResponse struct {
Accepted *[]string `json:"accepted"`
Rejected []wireRejection `json:"rejected"`
}
type attemptKind int
const (
attemptSucceeded attemptKind = iota + 1
attemptRejected
attemptUncertain
)
type attemptOutcome struct {
kind attemptKind
attempted bool
accepted []string
rejected []wireRejection
requestBytes int64
}
type httpError struct {
status int
detail string
retryable bool
batchInvalid bool
}
func (failure *httpError) Error() string {
if failure.status > 0 {
return fmt.Sprintf("telemetry: server returned %d: %s", failure.status, failure.detail)
}
return "telemetry: request failed: " + failure.detail
}
type uploadResult struct {
accepted []string
rejected []wireRejection
batchInvalid bool
}
func (c *Client) checkHealth(ctx context.Context) error {
requestContext, cancel := context.WithTimeout(ctx, c.limits.HealthTimeout)
defer cancel()
request, err := http.NewRequestWithContext(requestContext, http.MethodGet, c.endpoint+"/health", nil)
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", c.userAgent())
response, err := c.http.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("telemetry: health check returned %s", response.Status)
}
return nil
}
func (c *Client) uploadRecords(ctx context.Context, records []wireRecord, state *persistedState) (uploadResult, error) {
var lastErr error
for attempt := 1; attempt <= c.limits.MaxAttempts; attempt++ {
outcome, err := c.postOnce(ctx, records)
if outcome.attempted {
c.recordAttempt(state, outcome)
}
if err == nil {
return uploadResult{
accepted: outcome.accepted,
rejected: outcome.rejected,
}, nil
}
lastErr = err
failure, isHTTP := errors.AsType[*httpError](err)
retryable := isHTTP && failure.retryable
if !retryable || attempt == c.limits.MaxAttempts || ctx.Err() != nil {
if isHTTP {
return uploadResult{batchInvalid: failure.batchInvalid}, err
}
return uploadResult{}, err
}
delay := c.limits.RetryBackoff
for step := 1; step < attempt; step++ {
delay = min(delay*2, 2*time.Second)
}
if err := sleepContext(ctx, delay); err != nil {
return uploadResult{}, err
}
}
return uploadResult{}, lastErr
}
func (c *Client) recordAttempt(state *persistedState, outcome attemptOutcome) {
state.Transport.Requests++
state.Transport.RequestBodyBytes += outcome.requestBytes
state.Transport.LastRequestBodyBytes = outcome.requestBytes
switch outcome.kind {
case attemptSucceeded:
state.Transport.Succeeded++
case attemptRejected:
state.Transport.RejectedRequests++
case attemptUncertain:
state.Transport.Uncertain++
}
}
func (c *Client) postOnce(ctx context.Context, records []wireRecord) (attemptOutcome, error) {
body, err := marshalCanonical(wireRequest{Records: records})
if err != nil {
return attemptOutcome{}, fmt.Errorf("telemetry: encode request: %w", err)
}
if len(body) > c.limits.MaxRequestBytes {
return attemptOutcome{}, fmt.Errorf("telemetry: request body is %d bytes, limit %d", len(body), c.limits.MaxRequestBytes)
}
requestContext, cancel := context.WithTimeout(ctx, c.limits.RequestTimeout)
defer cancel()
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, c.endpoint+"/v1/records", bytes.NewReader(body))
if err != nil {
return attemptOutcome{}, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", c.userAgent())
outcome := attemptOutcome{attempted: true, requestBytes: int64(len(body))}
response, err := c.http.Do(request)
if err != nil {
return attemptOutcome{kind: attemptUncertain, attempted: true, requestBytes: int64(len(body))}, &httpError{
detail: sanitizeErrorText(err),
retryable: true,
}
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1))
if readErr != nil {
outcome.kind = attemptUncertain
return outcome, &httpError{detail: "read response: " + sanitizeErrorText(readErr), retryable: true}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
detail, _ := sanitizeText(string(responseBody), 256)
detail = strings.Join(strings.Fields(detail), " ")
if detail == "" {
detail = response.Status
}
failure := &httpError{
status: response.StatusCode,
detail: detail,
retryable: retryableStatus(response.StatusCode),
batchInvalid: response.StatusCode == http.StatusBadRequest,
}
outcome.kind = attemptRejected
return outcome, failure
}
if len(responseBody) > maxResponseBytes {
outcome.kind = attemptUncertain
return outcome, &httpError{detail: "response exceeds size limit", retryable: true}
}
var decoded wireResponse
if err := json.Unmarshal(responseBody, &decoded); err != nil {
outcome.kind = attemptUncertain
return outcome, &httpError{detail: "invalid response: " + sanitizeErrorText(err), retryable: true}
}
if decoded.Accepted == nil {
outcome.kind = attemptUncertain
return outcome, &httpError{detail: "response omitted accepted ids", retryable: true}
}
accepted := make([]string, 0, len(*decoded.Accepted))
for _, raw := range *decoded.Accepted {
id, err := normalizeUUID(raw)
if err != nil {
outcome.kind = attemptUncertain
return outcome, &httpError{detail: "response contained an invalid accepted id", retryable: true}
}
accepted = append(accepted, id)
}
rejected := make([]wireRejection, 0, len(decoded.Rejected))
for _, raw := range decoded.Rejected {
id, err := normalizeUUID(raw.ID)
if err != nil {
outcome.kind = attemptUncertain
return outcome, &httpError{detail: "response contained an invalid rejected id", retryable: true}
}
reason, _ := sanitizeText(raw.Reason, 128)
rejected = append(rejected, wireRejection{ID: id, Reason: strings.Join(strings.Fields(reason), " ")})
}
outcome.kind = attemptSucceeded
outcome.accepted = accepted
outcome.rejected = rejected
return outcome, nil
}
func retryableStatus(status int) bool {
switch status {
case http.StatusRequestTimeout, http.StatusTooEarly, http.StatusTooManyRequests:
return true
}
return status >= 500
}
func (c *Client) userAgent() string {
if c.version == "" {
return "lucy-telemetry"
}
return "lucy-telemetry/" + c.version
}
func sleepContext(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// Package progress presents command progress on stderr.
//
// A renderer is safe for concurrent use. Long phases announce themselves with
// PhaseStarted and PhaseDone, transient work with Status and Download, and
// permanent findings with Println, which keeps them above the animated area.
package progress
import (
"io"
"time"
"github.com/mclucy/lucy/terminal/style"
)
// Options carries the presentation state a renderer needs. The caller owns
// every value: a renderer never reads process streams or global styles.
type Options struct {
// Writer receives every permanent line and, when animated, the live area.
Writer io.Writer
// Theme styles progress text.
Theme style.Theme
// Terminal describes the capabilities of Writer.
Terminal style.Terminal
// Interactive allows an animated area when Terminal is a terminal.
Interactive bool
}
// Renderer presents one command's progress.
type Renderer interface {
// PhaseStarted announces a long-running stage.
PhaseStarted(text string)
// PhaseDone reports that the stage ended and prints it permanently.
PhaseDone(text string, elapsed time.Duration)
// Status replaces the live status line. Non-interactive renderers print it
// at most once per heartbeat interval.
Status(text string)
// Download reports byte progress for the artifact being transferred.
Download(name string, read, total int64)
// DownloadDone clears the artifact progress.
DownloadDone()
// Println writes permanent lines above the live area.
Println(lines ...string)
// LogWriter returns a writer whose complete lines appear in the live area.
// A subprocess uses it to stream its output without owning the terminal.
LogWriter() io.Writer
// Close stops the renderer. It is safe to call more than once. An animated
// renderer emits a subprocess line still waiting for its newline before it
// stops.
Close()
}
// New returns the renderer that suits options: an animated area only when the
// caller allows interactivity and Writer is a terminal, append-only lines
// otherwise. A nil Writer yields Discard, which suits machine-readable
// commands that must keep stderr free of progress.
func New(options Options) Renderer {
if options.Writer == nil {
return Discard()
}
if options.Interactive && options.Terminal.IsTTY {
return newLive(options)
}
return newPlain(options.Writer, options.Theme)
}
// Discard drops every update. It suits machine-readable commands, which must
// keep stderr free of progress.
func Discard() Renderer { return discardRenderer{} }
type discardRenderer struct{}
func (discardRenderer) PhaseStarted(string) {}
func (discardRenderer) PhaseDone(string, time.Duration) {}
func (discardRenderer) Status(string) {}
func (discardRenderer) Download(string, int64, int64) {}
func (discardRenderer) DownloadDone() {}
func (discardRenderer) Println(...string) {}
func (discardRenderer) LogWriter() io.Writer { return io.Discard }
func (discardRenderer) Close() {}
package progress
import (
"fmt"
"time"
"github.com/mclucy/lucy/terminal/style"
)
// FormatElapsed renders a phase duration for people: 0.06s, 35.8s, 1m 02s.
func FormatElapsed(elapsed time.Duration) string {
switch {
case elapsed < time.Second:
return fmt.Sprintf("%.2fs", elapsed.Seconds())
case elapsed < time.Minute:
return fmt.Sprintf("%.1fs", elapsed.Seconds())
default:
minutes := int(elapsed / time.Minute)
seconds := int((elapsed % time.Minute) / time.Second)
return fmt.Sprintf("%dm %02ds", minutes, seconds)
}
}
func phaseLine(text string, elapsed time.Duration, theme style.Theme) string {
return theme.Success("✓") + " " + text + " " + theme.Muted("in "+FormatElapsed(elapsed))
}
package progress
import (
"io"
"strings"
"sync"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/mclucy/lucy/terminal/style"
)
const (
liveTick = 100 * time.Millisecond
liveLogLines = 4
spinnerFrames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
)
var spinnerRunes = []rune(spinnerFrames)
type liveState struct {
phase string
status string
download string
read int64
total int64
logs []string
}
type liveSnapshot struct {
state liveState
frame int
}
// livePrint asks the model to emit a permanent line above the live area. The
// model closes done once the line is queued for rendering.
type livePrint struct {
text string
done chan struct{}
}
// live renders one animated area on stderr. Updates are coalesced by a ticker
// so byte-level progress never floods the renderer.
type live struct {
out io.Writer
theme style.Theme
program *tea.Program
done chan struct{}
flushDone chan struct{}
stop chan struct{}
mu sync.Mutex
state liveState
frame int
dirty bool
closed bool
partial strings.Builder
}
func newLive(options Options) *live {
width := options.Terminal.Width
if width <= 0 {
width = style.DefaultTerminalWidth
}
l := &live{
out: options.Writer,
theme: options.Theme,
done: make(chan struct{}),
flushDone: make(chan struct{}),
stop: make(chan struct{}),
}
model := &liveModel{width: width, theme: options.Theme}
l.program = tea.NewProgram(
model,
tea.WithOutput(options.Writer),
tea.WithInput(nil),
tea.WithoutSignalHandler(),
)
go func() {
defer close(l.done)
_, _ = l.program.Run()
}()
go l.tick()
return l
}
func (l *live) tick() {
ticker := time.NewTicker(liveTick)
defer ticker.Stop()
for {
select {
case <-l.stop:
return
case <-ticker.C:
l.mu.Lock()
if !l.dirty || l.closed {
l.mu.Unlock()
continue
}
l.dirty = false
l.frame++
snapshot := liveSnapshot{state: l.state, frame: l.frame}
l.mu.Unlock()
l.program.Send(snapshot)
}
}
}
func (l *live) mutate(change func(*liveState)) {
l.mu.Lock()
defer l.mu.Unlock()
if l.closed {
return
}
change(&l.state)
l.dirty = true
}
func (l *live) PhaseStarted(text string) {
l.mutate(func(state *liveState) {
state.phase = text
state.status = ""
state.download = ""
state.read = 0
state.total = 0
})
}
func (l *live) PhaseDone(text string, elapsed time.Duration) {
l.mutate(func(state *liveState) {
state.phase = ""
state.status = ""
state.download = ""
state.read = 0
state.total = 0
})
l.Println(phaseLine(text, elapsed, l.theme))
}
func (l *live) Status(text string) {
l.mutate(func(state *liveState) { state.status = text })
}
func (l *live) Download(name string, read, total int64) {
l.mutate(func(state *liveState) {
state.download = name
state.read = read
state.total = total
})
}
func (l *live) DownloadDone() {
l.mutate(func(state *liveState) {
state.download = ""
state.read = 0
state.total = 0
})
}
func (l *live) Println(lines ...string) {
if len(lines) == 0 {
return
}
text := strings.Join(lines, "\n")
l.mu.Lock()
if l.closed {
l.mu.Unlock()
// The caller that closed the renderer may still emit a trailing
// subprocess line. Wait for it before writing past the program.
<-l.flushDone
l.mu.Lock()
defer l.mu.Unlock()
_, _ = io.WriteString(l.out, text+"\n")
return
}
// The lock stays held until the model acknowledges the line, so Close
// cannot quit the program while a permanent line is still in flight.
defer l.mu.Unlock()
done := make(chan struct{})
// Send, unlike Program.Println, returns when the program has stopped, so a
// late line cannot block shutdown.
l.program.Send(livePrint{text: text, done: done})
select {
case <-done:
case <-l.done:
}
}
func (l *live) LogWriter() io.Writer { return logTail{l} }
func (l *live) appendLog(line string) {
l.mutate(func(state *liveState) {
state.logs = append(state.logs, line)
if len(state.logs) > liveLogLines {
state.logs = state.logs[len(state.logs)-liveLogLines:]
}
})
}
func (l *live) Close() {
l.mu.Lock()
if l.closed {
l.mu.Unlock()
// Another caller owns the final frame and the trailing line. Waiting
// for it keeps later output behind both.
<-l.flushDone
return
}
l.closed = true
partial := l.partial.String()
l.partial.Reset()
l.mu.Unlock()
close(l.stop)
l.program.Quit()
<-l.done
// The model learns log lines only from snapshots, so a trailing line
// without its newline terminator has no frame left to appear in. Emit it
// once as permanent output now that the program released the terminal.
if strings.TrimSpace(partial) != "" {
l.mu.Lock()
_, _ = io.WriteString(l.out, liveLogLine(l.theme, partial)+"\n")
l.mu.Unlock()
}
close(l.flushDone)
}
// liveLogLine formats one subprocess line the way the animated area shows it,
// so a line flushed at shutdown keeps the same shape.
func liveLogLine(theme style.Theme, line string) string {
return " " + theme.Muted("│ "+line)
}
type liveModel struct {
state liveState
frame int
width int
theme style.Theme
}
func (m *liveModel) Init() tea.Cmd { return nil }
func (m *liveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case liveSnapshot:
m.state = msg.state
m.frame = msg.frame
case livePrint:
return m, tea.Sequence(tea.Println(msg.text), func() tea.Msg {
close(msg.done)
return nil
})
case tea.WindowSizeMsg:
m.width = msg.Width
}
return m, nil
}
func (m *liveModel) View() tea.View {
width := m.width
if width <= 0 {
width = style.DefaultTerminalWidth
}
var lines []string
for _, line := range m.state.logs {
lines = append(lines, liveLogLine(m.theme, line))
}
if m.state.phase != "" {
frame := spinnerRunes[m.frame%len(spinnerRunes)]
head := m.theme.Note(string(frame)) + " " + m.theme.Accent(m.state.phase)
if m.state.status != "" {
available := width - lipgloss.Width(head) - 2
status := m.state.status
if available >= 0 && lipgloss.Width(status) > available {
status = ansi.Truncate(status, available, "…")
}
head += m.theme.Muted(": " + status)
}
lines = append(lines, clip(head, width))
}
if m.state.download != "" {
lines = append(lines, clip(m.downloadLine(width), width))
}
return tea.NewView(strings.Join(lines, "\n"))
}
func (m *liveModel) downloadLine(width int) string {
read := style.FormatBytesDecimal(m.state.read)
total := style.FormatBytesDecimal(m.state.total)
values := read + " / " + total
suffix := " " + m.theme.Muted(values)
prefix := " " + m.state.download + " "
barWidth := width - lipgloss.Width(prefix) - lipgloss.Width(values) - 2
if barWidth < 8 {
barWidth = 8
}
percent := 0.0
if m.state.total > 0 {
percent = float64(m.state.read) / float64(m.state.total)
if percent > 1 {
percent = 1
}
}
filled := int(percent * float64(barWidth))
bar := m.theme.Note(strings.Repeat("█", filled)) +
m.theme.Muted(strings.Repeat("░", barWidth-filled))
return prefix + bar + suffix
}
func clip(line string, width int) string {
if lipgloss.Width(line) <= width {
return line
}
return ansi.Truncate(line, width, "")
}
// logTail converts a subprocess's writes into whole lines for the live area.
type logTail struct{ renderer *live }
func (w logTail) Write(data []byte) (int, error) {
for _, line := range w.renderer.bufferLog(data) {
if strings.TrimSpace(line) != "" {
w.renderer.appendLog(line)
}
}
return len(data), nil
}
// bufferLog appends a subprocess chunk and returns the whole lines it
// completed. A closed renderer drops the chunk, as plain output does, so a
// late write cannot reach the terminal after the final frame.
func (l *live) bufferLog(data []byte) []string {
l.mu.Lock()
defer l.mu.Unlock()
if l.closed {
return nil
}
l.partial.Write(data)
complete, remainder, found := strings.CutLast(l.partial.String(), "\n")
if !found {
return nil
}
lines := strings.Split(complete, "\n")
l.partial.Reset()
l.partial.WriteString(remainder)
return lines
}
package progress
import (
"io"
"strings"
"sync"
"time"
"github.com/mclucy/lucy/terminal/style"
)
const plainHeartbeat = 10 * time.Second
// plain writes append-only lines. It suits pipes, logs, and CI, where a
// redrawn area would only add noise.
type plain struct {
out io.Writer
theme style.Theme
mu sync.Mutex
phase string
phaseStart time.Time
status string
lastStatus string
lastPrint time.Time
closed bool
stop chan struct{}
done chan struct{}
}
func newPlain(out io.Writer, theme style.Theme) *plain {
p := &plain{out: out, theme: theme, stop: make(chan struct{}), done: make(chan struct{})}
go p.heartbeat()
return p
}
func (p *plain) heartbeat() {
defer close(p.done)
ticker := time.NewTicker(plainHeartbeat)
defer ticker.Stop()
for {
select {
case <-p.stop:
return
case <-ticker.C:
p.mu.Lock()
if !p.closed && p.status != "" && p.status != p.lastStatus {
p.lastStatus = p.status
p.lastPrint = time.Now()
line := " " + p.status + p.elapsedSuffix()
if p.phase != "" {
line = " " + p.phase + ": " + p.status + p.elapsedSuffix()
}
p.mu.Unlock()
p.println(line)
continue
}
p.mu.Unlock()
}
}
}
func (p *plain) elapsedSuffix() string {
if p.phaseStart.IsZero() {
return ""
}
return p.theme.Muted(" (" + FormatElapsed(time.Since(p.phaseStart)) + ")")
}
func (p *plain) PhaseStarted(text string) {
p.mu.Lock()
defer p.mu.Unlock()
p.phase = text
p.phaseStart = time.Now()
p.status = ""
p.lastStatus = ""
}
func (p *plain) PhaseDone(text string, elapsed time.Duration) {
p.mu.Lock()
p.phase = ""
p.phaseStart = time.Time{}
p.status = ""
p.lastStatus = ""
p.mu.Unlock()
p.println(phaseLine(text, elapsed, p.theme))
}
func (p *plain) Status(text string) {
p.mu.Lock()
p.status = text
p.mu.Unlock()
}
func (p *plain) Download(string, int64, int64) {}
func (p *plain) DownloadDone() {}
func (p *plain) Println(lines ...string) {
for _, line := range lines {
p.println(line)
}
}
func (p *plain) LogWriter() io.Writer { return plainLogWriter{p} }
func (p *plain) Close() {
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return
}
p.closed = true
p.mu.Unlock()
close(p.stop)
<-p.done
}
func (p *plain) println(line string) {
p.mu.Lock()
defer p.mu.Unlock()
if line == "" {
_, _ = io.WriteString(p.out, "\n")
return
}
_, _ = io.WriteString(p.out, line+"\n")
}
type plainLogWriter struct{ renderer *plain }
func (w plainLogWriter) Write(data []byte) (int, error) {
w.renderer.mu.Lock()
defer w.renderer.mu.Unlock()
if w.renderer.closed {
return io.Discard.Write(data)
}
for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
_, _ = io.WriteString(w.renderer.out, w.renderer.theme.Muted(" │ "+line)+"\n")
}
return len(data), nil
}
package style
import (
"io"
"os"
"sync"
"charm.land/lipgloss/v2"
"golang.org/x/term"
)
// Conventional geometry when the stream cannot report its own size.
const (
DefaultTerminalWidth = 80
DefaultTerminalHeight = 24
)
// Terminal describes the capabilities of one output stream. Presentation that
// renders to a stream derives its geometry and terminal state from that
// stream alone, never from a package-level snapshot.
type Terminal struct {
Width int
Height int
IsTTY bool
Dark bool
background func() bool
}
// DetectTerminals detects geometry immediately and queries the background only
// when colored Markdown needs it. Streams on the same terminal share a query.
func DetectTerminals(input io.Reader, stdout, stderr io.Writer) (Terminal, Terminal) {
out := detectTerminal(input, stdout)
errOut := detectTerminal(input, stderr)
if out.background != nil && errOut.background != nil {
outFile := stdout.(*os.File)
errFile := stderr.(*os.File)
outInfo, outErr := outFile.Stat()
errInfo, errErr := errFile.Stat()
if outErr == nil && errErr == nil && os.SameFile(outInfo, errInfo) {
errOut.background = out.background
}
}
return out, errOut
}
func detectTerminal(input io.Reader, w io.Writer) Terminal {
detected := Terminal{
Width: DefaultTerminalWidth,
Height: DefaultTerminalHeight,
Dark: true,
}
file, ok := w.(*os.File)
if !ok || !term.IsTerminal(int(file.Fd())) {
return detected
}
detected.IsTTY = true
if width, height, err := term.GetSize(int(file.Fd())); err == nil {
if width > 0 {
detected.Width = width
}
if height > 0 {
detected.Height = height
}
}
if terminalInput, ok := input.(*os.File); ok && term.IsTerminal(int(terminalInput.Fd())) {
detected.background = sync.OnceValue(func() bool {
return lipgloss.HasDarkBackground(terminalInput, file)
})
}
return detected
}
package style
import (
"fmt"
"strings"
"time"
"charm.land/glamour/v2"
"charm.land/glamour/v2/ansi"
"charm.land/glamour/v2/styles"
"charm.land/lipgloss/v2"
"github.com/mclucy/lucy/internal/fn"
)
const CRLF = "\r\n"
// Markdown renders Markdown with the theme's color choice and the output
// stream's capabilities. A width of zero falls back to the terminal width.
func (t Theme) Markdown(md string, maxWidth int, terminal Terminal) string {
trimmed := strings.TrimSpace(md)
if trimmed == "" {
return ""
}
if maxWidth <= 0 {
maxWidth = terminal.Width
}
if maxWidth <= 0 {
maxWidth = DefaultTerminalWidth
}
options := []glamour.TermRendererOption{
glamour.WithWordWrap(maxWidth),
}
if t.colors {
dark := terminal.Dark
if terminal.background != nil {
dark = terminal.background()
}
if dark {
options = append(options, glamour.WithStandardStyle(styles.DarkStyle))
} else {
options = append(options, glamour.WithStandardStyle(styles.LightStyle))
}
} else {
options = append(options, glamour.WithStyles(noColorMarkdownStyle()))
}
renderer, err := glamour.NewTermRenderer(options...)
defer fn.CloseReader(renderer, func(err error) {})
if err != nil {
return trimmed
}
rendered, err := renderer.Render(trimmed)
if err != nil {
return trimmed
}
rendered = strings.TrimRight(rendered, "\n")
lines := strings.Split(rendered, "\n")
for i, line := range lines {
if lipgloss.Width(line) > maxWidth {
lines[i] = lipgloss.Wrap(line, maxWidth, "")
}
}
return strings.Join(lines, "\n")
}
// noColorMarkdownStyle renders Markdown without color while keeping emphasis
// as terminal attributes. The stock notty style falls back to literal
// `**bold**` and `*italic*` markers, which contradicts --no-color keeping
// text attributes.
func noColorMarkdownStyle() ansi.StyleConfig {
bold, italic, crossedOut := true, true, true
cfg := styles.NoTTYStyleConfig
cfg.Strong = ansi.StylePrimitive{Bold: &bold}
cfg.Emph = ansi.StylePrimitive{Italic: &italic}
cfg.Strikethrough = ansi.StylePrimitive{CrossedOut: &crossedOut}
return cfg
}
func Capitalize(v any) string {
s, ok := v.(string)
if !ok {
s = fmt.Sprintf("%v", v)
}
if len(s) == 0 {
return ""
}
return strings.ToUpper(s[:1]) + s[1:]
}
func FormatBytesBinary(bytes int64) string {
const (
kib = 1024
mib = kib * 1024
gib = mib * 1024
)
switch {
case bytes >= gib:
return fmt.Sprintf("%.1f GiB", float64(bytes)/float64(gib))
case bytes >= mib:
return fmt.Sprintf("%.1f MiB", float64(bytes)/float64(mib))
case bytes >= kib:
return fmt.Sprintf("%.1f KiB", float64(bytes)/float64(kib))
default:
return fmt.Sprintf("%d B", bytes)
}
}
func FormatBytesDecimal(bytes int64) string {
const (
kb = 1000
mb = kb * 1000
gb = mb * 1000
)
switch {
case bytes >= gb:
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(gb))
case bytes >= mb:
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
case bytes >= kb:
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
default:
return fmt.Sprintf("%d B", bytes)
}
}
func FormatTTL(t time.Time) string {
remaining := time.Until(t)
if remaining <= 0 {
return "expired"
}
switch {
case remaining >= 24*time.Hour:
days := int(remaining.Hours() / 24)
return fmt.Sprintf("expires in %dd", days)
case remaining >= time.Hour:
return fmt.Sprintf("expires in %dh", int(remaining.Hours()))
default:
return fmt.Sprintf("expires in %dm", int(remaining.Minutes()))
}
}
package style
import (
"fmt"
"image/color"
"charm.land/lipgloss/v2"
)
// Theme is the immutable presentation style for one invocation. Construct it
// with New; every method returns rendered text and never mutates the theme.
// The zero value renders plain text without attributes.
type Theme struct {
colors bool
bold lipgloss.Style
dim lipgloss.Style
italic lipgloss.Style
underline lipgloss.Style
red lipgloss.Style
green lipgloss.Style
yellow lipgloss.Style
blue lipgloss.Style
magenta lipgloss.Style
cyan lipgloss.Style
key lipgloss.Style
}
// New builds a theme. When colors is false, foreground colors are dropped
// while text attributes such as bold, faint, italic, and underline survive,
// so --no-color output stays readable.
func New(colors bool) Theme {
colored := func(fg color.Color) lipgloss.Style {
base := lipgloss.NewStyle()
if !colors {
return base
}
return base.Foreground(fg)
}
return Theme{
colors: colors,
bold: lipgloss.NewStyle().Bold(true),
dim: lipgloss.NewStyle().Faint(true),
italic: lipgloss.NewStyle().Italic(true),
underline: lipgloss.NewStyle().Underline(true),
red: colored(lipgloss.Red),
green: colored(lipgloss.Green),
yellow: colored(lipgloss.Yellow),
blue: colored(lipgloss.Blue),
magenta: colored(lipgloss.Magenta),
cyan: colored(lipgloss.Cyan),
key: colored(lipgloss.Magenta).Bold(true),
}
}
// Colors reports whether the theme may carry ANSI foreground colors.
func (t Theme) Colors() bool { return t.colors }
func (t Theme) Bold(v any) string { return renderStyle(t.bold, v) }
func (t Theme) Dim(v any) string { return renderStyle(t.dim, v) }
func (t Theme) Italic(v any) string { return renderStyle(t.italic, v) }
func (t Theme) Underline(v any) string { return renderStyle(t.underline, v) }
func (t Theme) Red(v any) string { return renderStyle(t.red, v) }
func (t Theme) Green(v any) string { return renderStyle(t.green, v) }
func (t Theme) Yellow(v any) string { return renderStyle(t.yellow, v) }
func (t Theme) Blue(v any) string { return renderStyle(t.blue, v) }
func (t Theme) Magenta(v any) string { return renderStyle(t.magenta, v) }
func (t Theme) Cyan(v any) string { return renderStyle(t.cyan, v) }
// Semantic roles — use these instead of raw colors.
// Key styles KV field labels (bold magenta).
func (t Theme) Key(v any) string { return renderStyle(t.key, v) }
// Muted styles secondary information and annotations (faint).
func (t Theme) Muted(v any) string { return renderStyle(t.dim, v) }
// Accent styles emphasized names and highlights (bold).
func (t Theme) Accent(v any) string { return renderStyle(t.bold, v) }
// Success styles checkmarks and completion (green).
func (t Theme) Success(v any) string { return renderStyle(t.green, v) }
// Failure styles error indicators (red).
func (t Theme) Failure(v any) string { return renderStyle(t.red, v) }
// Warning styles warnings (yellow).
func (t Theme) Warning(v any) string { return renderStyle(t.yellow, v) }
// Link styles URLs (underline).
func (t Theme) Link(v any) string { return renderStyle(t.underline, v) }
// Note styles informational markers (cyan).
func (t Theme) Note(v any) string { return renderStyle(t.cyan, v) }
func renderStyle(s lipgloss.Style, v any) string {
switch v := v.(type) {
case rune:
return s.Render(string(v))
default:
return s.Render(fmt.Sprintf("%v", v))
}
}
// Package terminal renders human-facing status and fact views.
package terminal
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/mclucy/lucy/terminal/style"
)
// Fact is a human-facing label with a possibly multiline value.
type Fact struct {
Key string
Value string
}
// RenderFacts uses one label column for the entire document.
func RenderFacts(facts []Fact, width int, theme style.Theme) string {
return RenderFactGroups([][]Fact{facts}, width, theme)
}
// RenderFactGroups separates groups without shifting the shared label column.
func RenderFactGroups(groups [][]Fact, width int, theme style.Theme) string {
keyWidth := 0
for _, group := range groups {
for _, fact := range group {
keyWidth = max(keyWidth, ansi.StringWidth(fact.Key))
}
}
keyWidth = min(keyWidth, max(1, width/3))
valueWidth := max(1, width-keyWidth-2)
indent := strings.Repeat(" ", keyWidth+2)
var output strings.Builder
for _, group := range groups {
if len(group) == 0 {
continue
}
if output.Len() > 0 {
output.WriteByte('\n')
}
for _, fact := range group {
key := TruncateMiddle(fact.Key, keyWidth)
value := lipgloss.Wrap(fact.Value, valueWidth, "")
for i, line := range strings.Split(value, "\n") {
prefix := indent
if i == 0 {
prefix = theme.Key(key) + strings.Repeat(" ", keyWidth-ansi.StringWidth(key)+2)
}
if output.Len() > 0 {
output.WriteByte('\n')
}
output.WriteString(strings.TrimRight(prefix+line, " \t\r"))
}
}
}
return output.String()
}
package terminal
// StatusLayoutMode describes how the status view arranges the logo and info
// blocks within the available terminal width.
type StatusLayoutMode int
const (
// LayoutLargeLogoSideBySide places the large logo to the left of the
// info block with a gap between them.
LayoutLargeLogoSideBySide StatusLayoutMode = iota
// LayoutSmallLogoSideBySide places the small logo to the left of the
// info block with a gap between them.
LayoutSmallLogoSideBySide
// LayoutVertical stacks the logo above the info block.
LayoutVertical
// LayoutClipped renders only the info block, clipped to the terminal
// width. Used when the terminal is narrower than minInfoWidth.
LayoutClipped
// LayoutInfoOnly renders only the info block with no logo at all.
// Used in non-TTY (piped) contexts and when --logo=none.
LayoutInfoOnly
)
const (
// statusLayoutGapWidth is the number of blank columns between the logo
// and info blocks in side-by-side modes.
statusLayoutGapWidth = 3
// statusLayoutMinInfoWidth is the minimum number of columns the info
// block needs to be useful.
statusLayoutMinInfoWidth = 40
)
// StatusLayoutParams holds the result of layout negotiation: the chosen mode
// and the width budgets for each visual element.
type StatusLayoutParams struct {
Mode StatusLayoutMode
LogoWidth int
InfoWidth int
GapWidth int
}
func statusLayoutInfoOnly(termWidth int) StatusLayoutParams {
return StatusLayoutParams{
Mode: LayoutInfoOnly,
LogoWidth: 0,
InfoWidth: termWidth,
GapWidth: 0,
}
}
func statusLayoutSmallLogo(termWidth int, logoSmallWidth int) StatusLayoutParams {
if termWidth >= logoSmallWidth+statusLayoutGapWidth+statusLayoutMinInfoWidth {
return StatusLayoutParams{
Mode: LayoutSmallLogoSideBySide,
LogoWidth: logoSmallWidth,
InfoWidth: termWidth - logoSmallWidth - statusLayoutGapWidth,
GapWidth: statusLayoutGapWidth,
}
}
if termWidth >= statusLayoutMinInfoWidth {
return StatusLayoutParams{
Mode: LayoutVertical,
LogoWidth: logoSmallWidth,
InfoWidth: termWidth,
GapWidth: 0,
}
}
return StatusLayoutParams{
Mode: LayoutClipped,
LogoWidth: 0,
InfoWidth: termWidth,
GapWidth: 0,
}
}
func statusLayoutLargeLogo(termWidth int, logoLargeWidth int) StatusLayoutParams {
if termWidth >= logoLargeWidth+statusLayoutGapWidth+statusLayoutMinInfoWidth {
return StatusLayoutParams{
Mode: LayoutLargeLogoSideBySide,
LogoWidth: logoLargeWidth,
InfoWidth: termWidth - logoLargeWidth - statusLayoutGapWidth,
GapWidth: statusLayoutGapWidth,
}
}
if termWidth >= statusLayoutMinInfoWidth {
return StatusLayoutParams{
Mode: LayoutVertical,
LogoWidth: logoLargeWidth,
InfoWidth: termWidth,
GapWidth: 0,
}
}
return StatusLayoutParams{
Mode: LayoutClipped,
LogoWidth: 0,
InfoWidth: termWidth,
GapWidth: 0,
}
}
func statusLayoutAuto(
termWidth int,
logoLargeWidth int,
logoSmallWidth int,
) StatusLayoutParams {
if termWidth >= logoLargeWidth+statusLayoutGapWidth+statusLayoutMinInfoWidth {
return statusLayoutLargeLogo(termWidth, logoLargeWidth)
}
if termWidth >= logoSmallWidth+statusLayoutGapWidth+statusLayoutMinInfoWidth {
return statusLayoutSmallLogo(termWidth, logoSmallWidth)
}
return statusLayoutLargeLogo(termWidth, logoLargeWidth)
}
// NegotiateStatusLayout decides which layout mode to use given the terminal
// width, the widths of the two logo variants, TTY detection, and logo mode.
// It returns the mode together with column budgets so the status renderer can
// compose the view without further arithmetic.
func NegotiateStatusLayout(
termWidth int,
logoLargeWidth int,
logoSmallWidth int,
isTTY bool,
logoMode StatusLogoMode,
) StatusLayoutParams {
if logoMode == StatusLogoNone || !isTTY {
return statusLayoutInfoOnly(termWidth)
}
switch logoMode {
case StatusLogoLarge:
return statusLayoutLargeLogo(termWidth, logoLargeWidth)
case StatusLogoSmall:
return statusLayoutSmallLogo(termWidth, logoSmallWidth)
default:
return statusLayoutAuto(termWidth, logoLargeWidth, logoSmallWidth)
}
}
package terminal
import (
"embed"
"io/fs"
"regexp"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/version"
)
// LogoVariant selects between the large and small logo variants.
type LogoVariant int
const (
// LogoLargePlain selects the full-size ASCII art logo.
LogoLargePlain LogoVariant = iota
// LogoSmallPlain selects the compact ASCII art logo.
LogoSmallPlain
LogoLargeColored
LogoSmallColored
)
// The logo assets are generated from assets/logos.yaml by tools/genlogo.
// Colored assets carry their own SGR escapes.
//
//go:embed assets/large_plain/*.txt assets/small_plain/*.txt assets/large_colored/*.txt assets/small_colored/*.txt
var logoAssets embed.FS
var embeddedLogoAssets = loadEmbeddedLogoAssets()
func loadEmbeddedLogoAssets() map[string]string {
assets := make(map[string]string)
err := fs.WalkDir(logoAssets, "assets", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
data, err := fs.ReadFile(logoAssets, path)
if err != nil {
return err
}
assets[strings.TrimPrefix(path, "assets/")] = string(data)
return nil
})
if err != nil {
panic(err)
}
return assets
}
// GetLogo returns the logo for the server product, ecosystem, Minecraft
// version, and variant.
func GetLogo(
product server.Product,
ecosystem catalog.Ecosystem,
minecraftVersion version.Version,
variant LogoVariant,
) string {
name := logoAssetName(product, ecosystem, minecraftVersion)
if name == "" {
return ""
}
directory, ok := logoVariantDirectory(variant)
if !ok {
return ""
}
return embeddedLogoAssets[directory+"/"+name+".txt"]
}
func logoVariantDirectory(variant LogoVariant) (string, bool) {
switch variant {
case LogoLargePlain:
return "large_plain", true
case LogoSmallPlain:
return "small_plain", true
case LogoLargeColored:
return "large_colored", true
case LogoSmallColored:
return "small_colored", true
default:
return "", false
}
}
func logoAssetName(
product server.Product,
ecosystem catalog.Ecosystem,
minecraftVersion version.Version,
) string {
name := strings.ToLower(strings.TrimSpace(product.String()))
switch name {
case "minecraft", "vanilla":
if strings.Contains(strings.ToLower(minecraftVersion.String()), "snapshot") ||
snapshotVersionPattern.MatchString(minecraftVersion.String()) {
return "vanilla-snapshot"
}
return "vanilla"
case "paper":
return "papermc"
case "mcdreforged":
return "mcdr"
case "bukkit", "craftbukkit":
return "spigot"
case "spongevanilla", "spongeforge", "spongeneo":
return "sponge"
}
if embeddedLogoAssets["small_plain/"+name+".txt"] != "" {
return name
}
switch ecosystem {
case catalog.EcosystemFabric:
return "fabric"
case catalog.EcosystemForge:
return "forge"
case catalog.EcosystemNeoForge:
return "neoforge"
case catalog.EcosystemBukkit, catalog.EcosystemPaper:
return "spigot"
case catalog.EcosystemSponge:
return "sponge"
case catalog.EcosystemVelocity:
return "velocity"
case catalog.EcosystemMCDR:
return "mcdr"
default:
return ""
}
}
var snapshotVersionPattern = regexp.MustCompile(`^\d\dw\d+[a-z]$`)
package terminal
import "strings"
// StatusLogoMode selects how the status command shows the ASCII platform logo.
// The zero value leaves the mode unset for automatic layout selection.
type StatusLogoMode int
const (
statusLogoAuto StatusLogoMode = iota
// StatusLogoSmall selects the compact logo when layout allows.
StatusLogoSmall
// StatusLogoNone omits the logo.
StatusLogoNone
// StatusLogoLarge uses the full-size logo (side-by-side or stacked).
StatusLogoLarge
)
// ParseStatusLogoMode parses a --logo flag value (case-insensitive).
// Empty string means small (default).
func ParseStatusLogoMode(s string) (StatusLogoMode, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "small":
return StatusLogoSmall, true
case "none":
return StatusLogoNone, true
case "large":
return StatusLogoLarge, true
default:
return StatusLogoSmall, false
}
}
package terminal
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/x/ansi"
"github.com/mclucy/lucy/terminal/style"
)
func FoldHint(hidden int, theme style.Theme) string {
return theme.Muted(fmt.Sprintf("… %d more, use --long to expand", hidden))
}
func FoldLinesHint(hidden int, theme style.Theme) string {
return theme.Muted(fmt.Sprintf("… %d more lines, use --long to expand", hidden))
}
// CenterElide keeps both ends of prose and prefers complete paragraphs.
// The budget includes the omission marker; long output bypasses this function.
func CenterElide(text string, budget int, theme style.Theme) string {
lines := strings.Split(strings.TrimSpace(text), "\n")
if budget < 3 || len(lines) <= budget {
return text
}
head := (budget - 1 + 1) / 2
tail := len(lines) - (budget - 1 - head)
// Move cuts outward from the omitted range to the nearest paragraph edge.
// This never grows the visible output beyond its budget.
for head > 1 && strings.TrimSpace(ansi.Strip(lines[head])) != "" {
head--
}
for tail < len(lines)-1 && strings.TrimSpace(ansi.Strip(lines[tail-1])) != "" {
tail++
}
if head == 1 && tail == len(lines)-1 {
// A single paragraph has no internal boundary; retain readable ends.
head = budget / 2
tail = len(lines) - (budget - 1 - head)
}
for head > 0 && strings.TrimSpace(ansi.Strip(lines[head-1])) == "" {
head--
}
for tail < len(lines) && strings.TrimSpace(ansi.Strip(lines[tail])) == "" {
tail++
}
visible := append([]string{}, lines[:head]...)
visible = append(visible, FoldLinesHint(tail-head, theme))
visible = append(visible, lines[tail:]...)
return strings.Join(visible, "\n")
}
// TruncateMiddle preserves a path or reference's identifying suffix.
func TruncateMiddle(text string, width int) string {
length := ansi.StringWidth(text)
if width <= 0 {
return ""
}
if length <= width {
return text
}
if width == 1 {
return "…"
}
left := (width - 1) / 2
right := width - 1 - left
prefix := ansi.Cut(text, 0, left)
suffix := ansi.Cut(text, length-right, length)
prefixWidth, suffixWidth := ansi.StringWidth(prefix), ansi.StringWidth(suffix)
for prefixWidth+1+suffixWidth > width {
if left > 0 {
left--
prefix = ansi.Cut(text, 0, left)
prefixWidth = ansi.StringWidth(prefix)
} else {
right--
suffix = ansi.Cut(text, length-right, length)
suffixWidth = ansi.StringWidth(suffix)
}
}
return prefix + "…" + suffix
}
// DisplayPath uses workspace-relative paths, then the user's home shorthand.
func DisplayPath(root, path string) string {
if path == "" {
return ""
}
if !filepath.IsAbs(path) {
return filepath.ToSlash(filepath.Clean(path))
}
if relative, err := filepath.Rel(root, path); err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return filepath.ToSlash(relative)
}
if home, err := os.UserHomeDir(); err == nil {
if relative, err := filepath.Rel(home, path); err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
if relative == "." {
return "~"
}
return "~/" + filepath.ToSlash(relative)
}
}
return filepath.ToSlash(path)
}
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"flag"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
func main() {
check := flag.Bool("check", false, "fail if generated docs.tar.gz differs from docs/ content")
outPath := flag.String("out", "docs/docs.tar.gz", "output path for compressed documentation archive")
docsDir := flag.String("docs", "docs", "path to documentation source directory")
flag.Parse()
archive, err := buildArchive(*docsDir)
if err != nil {
fmt.Fprintf(os.Stderr, "[ERROR] build docs archive: %v\n", err)
os.Exit(1)
}
if *check {
existing, err := os.ReadFile(*outPath)
if err != nil {
fmt.Fprintf(os.Stderr, "[ERROR] read %s: %v\n", *outPath, err)
os.Exit(1)
}
if !bytes.Equal(archive, existing) {
fmt.Fprintf(os.Stderr, "[ERROR] %s is out of date; run task docs:pack\n", *outPath)
os.Exit(1)
}
fmt.Printf("[OK] %s is up to date\n", *outPath)
return
}
if err := os.WriteFile(*outPath, archive, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "[ERROR] write %s: %v\n", *outPath, err)
os.Exit(1)
}
fmt.Printf("[OK] Wrote %s (%d bytes)\n", *outPath, len(archive))
}
func buildArchive(rootDir string) ([]byte, error) {
var filePaths []string
err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".mdx") || strings.HasSuffix(path, ".draft.mdx") {
return nil
}
rel, err := filepath.Rel(rootDir, path)
if err != nil {
return err
}
filePaths = append(filePaths, rel)
return nil
})
if err != nil {
return nil, err
}
slices.Sort(filePaths)
var buf bytes.Buffer
gw, err := gzip.NewWriterLevel(&buf, gzip.BestCompression)
if err != nil {
return nil, err
}
// Deterministic gzip header: clear modification time
gw.Header.ModTime = time.Time{}
gw.Header.OS = 255 // unknown OS for reproducibility
tw := tar.NewWriter(gw)
for _, rel := range filePaths {
data, err := os.ReadFile(filepath.Join(rootDir, rel))
if err != nil {
return nil, err
}
header := &tar.Header{
Name: filepath.ToSlash(rel),
Mode: 0o644,
Size: int64(len(data)),
ModTime: time.Unix(0, 0),
Typeflag: tar.TypeReg,
}
if err := tw.WriteHeader(header); err != nil {
return nil, err
}
if _, err := tw.Write(data); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := gw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// Command genlogo generates the status logos from the upstream images that
// the logo manifest pins. Every variant gets a plain and a colored text file
// per logo; both hold the same glyphs.
package main
import (
"bytes"
"errors"
"flag"
"fmt"
"image"
"maps"
"os"
"path/filepath"
"slices"
"strings"
)
// defaultGlyphStyle draws filled shapes. Its letters give round corners and
// stems; letters that read as text instead of shape are left out.
const defaultGlyphStyle = "solid"
// glyphStyles maps a manifest glyph style to the glyphs that cells on shape
// edges choose from. The line style suits outline art, where letters along
// thin strokes read as noise.
var glyphStyles = map[string]string{
"solid": ` .,:;_-~=+*^"'` + "`" + `/\|()<>[]{}!?ilj7LTYyvxoadbpqg#@`,
"line": ` .,:;_-~^"'` + "`" + `/\|()<>[]{}!`,
}
// fillGlyphs fill uniform regions, picked by ink density.
const fillGlyphs = ".:-=+*#%@"
func main() {
defaultCache, _ := os.UserCacheDir()
manifestPath := flag.String("manifest", "terminal/assets/logos.yaml", "logo manifest")
outDir := flag.String("out", "terminal/assets", "directory that receives <variant>_plain/ and <variant>_colored/")
cacheDir := flag.String("cache", filepath.Join(defaultCache, "lucy-genlogo"), "download cache directory")
only := flag.String("only", "", "comma-separated logo ids to generate")
previewDir := flag.String("preview", "", "also write a PNG preview of each logo into this directory")
check := flag.Bool("check", false, "report generated files that differ from the files on disk, and write nothing")
flag.Parse()
m, err := loadManifest(*manifestPath)
if err != nil {
fatal("%v", err)
}
selected := map[string]bool{}
if *only != "" {
for id := range strings.SplitSeq(*only, ",") {
selected[strings.TrimSpace(id)] = true
}
}
gen, err := newGenerator(m, *cacheDir, *previewDir != "" && !*check)
if err != nil {
fatal("%v", err)
}
var files []outputFile
unknown := maps.Clone(selected)
for _, logo := range m.Logos {
if len(selected) > 0 && !selected[logo.ID] {
continue
}
delete(unknown, logo.ID)
result, err := gen.generate(logo)
if err != nil {
fatal("%s: %v", logo.ID, err)
}
files = append(files, result.files...)
if result.preview != nil && !*check {
if err := writePNG(filepath.Join(*previewDir, logo.ID+".png"), result.preview); err != nil {
fatal("%s: %v", logo.ID, err)
}
}
fmt.Printf("[OK] %s palette%s\n", logo.ID, result.palette)
}
for id := range unknown {
fatal("unknown logo id %q", id)
}
stale := []string{}
if *only == "" {
stale, err = staleFiles(*outDir, m)
if err != nil {
fatal("%v", err)
}
}
if *check {
if err := checkFiles(*outDir, files, stale); err != nil {
fatal("%v", err)
}
return
}
if err := writeFiles(*outDir, files, stale); err != nil {
fatal("%v", err)
}
}
// outputFile is one generated text file, relative to the output directory.
type outputFile struct {
path string
content string
}
type generator struct {
variants []variantSpec
cacheDir string
glyphs map[string]glyphSet
options renderOptions
preview *previewer
}
func newGenerator(m manifest, cacheDir string, withPreview bool) (generator, error) {
fontPath, err := fetchSource(m.Font, cacheDir)
if err != nil {
return generator{}, fmt.Errorf("font: %w", err)
}
f, err := loadCellFont(fontPath)
if err != nil {
return generator{}, err
}
face, err := cellFace(f, glyphCellWidth)
if err != nil {
return generator{}, err
}
gen := generator{
variants: m.Variants,
cacheDir: cacheDir,
glyphs: map[string]glyphSet{},
options: renderOptions{
uniformity: 0.2,
fill: newFillRamp(face, fillGlyphs),
},
}
windows := sampleWindows(3, 6, 0.5)
for style, glyphs := range glyphStyles {
gen.glyphs[style] = newGlyphSet(face, glyphs, windows)
}
if withPreview {
p, err := newPreviewer(f)
if err != nil {
return generator{}, err
}
gen.preview = &p
}
return gen, nil
}
// logoResult is the output of one logo. preview is nil unless the generator
// was built with previews. palette lists the palette colors with their
// tones, to help write a fixed palette into the manifest.
type logoResult struct {
files []outputFile
preview *image.NRGBA
palette string
}
// generate renders every variant of one logo.
func (gen generator) generate(logo logoSpec) (logoResult, error) {
path, err := fetchSource(logo.Source, gen.cacheDir)
if err != nil {
return logoResult{}, err
}
img, err := decodeImage(path, svgRasterSize)
if err != nil {
return logoResult{}, err
}
img, err = prepare(img, logo)
if err != nil {
return logoResult{}, err
}
pal, tones := logo.paletteAndTones(img)
if logo.Despeckle > 0 {
despeckle(img, pal, tones, logo.Despeckle)
}
planes := newInkPlanes(img, pal, tones)
if planes.bounds.Empty() {
return logoResult{}, errors.New("the source has no ink after preprocessing")
}
scale := 1
if logo.Pixel {
scale = detectPixelScale(img, planes.bounds)
if scale == 1 {
return logoResult{}, errors.New("pixel is set, but the source is not upscaled pixel art")
}
}
style := logo.Glyphs
if style == "" {
style = defaultGlyphStyle
}
result := logoResult{}
for i, c := range pal.colors {
result.palette += fmt.Sprintf(" %s:%.2f", hexColor(c), tones[i])
}
var rows []*image.NRGBA
for _, v := range gen.variants {
g, ok := grid{}, false
if scale > 1 {
g, ok = fitPixelGrid(planes.bounds, scale, v.Cols, v.Rows)
}
if !ok {
g = fitGrid(planes.bounds, v.Cols, v.Rows)
}
a := renderArt(planes, pal, g, gen.glyphs[style], gen.options)
result.files = append(result.files,
outputFile{path: filepath.Join(v.Name+"_plain", logo.ID+".txt"), content: plainText(a)},
outputFile{path: filepath.Join(v.Name+"_colored", logo.ID+".txt"), content: ansiText(a)},
)
if gen.preview != nil {
rows = append(rows, gen.preview.row(
[]*image.NRGBA{
gen.preview.render(a, previewDark, false),
gen.preview.render(a, previewDark, true),
gen.preview.render(a, previewLight, true),
},
[]string{fmt.Sprintf("%s %s %dx%d", logo.ID, v.Name, g.cols, g.rows)},
))
}
}
if gen.preview != nil {
result.preview = stack(rows)
}
return result, nil
}
// staleFiles lists text files in the variant directories that no logo in
// the manifest generates.
func staleFiles(outDir string, m manifest) ([]string, error) {
ids := map[string]bool{}
for _, logo := range m.Logos {
ids[logo.ID+".txt"] = true
}
var stale []string
for _, v := range m.Variants {
for _, dir := range []string{v.Name + "_plain", v.Name + "_colored"} {
entries, err := os.ReadDir(filepath.Join(outDir, dir))
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
for _, e := range entries {
if !e.IsDir() && filepath.Ext(e.Name()) == ".txt" && !ids[e.Name()] {
stale = append(stale, filepath.Join(dir, e.Name()))
}
}
}
}
slices.Sort(stale)
return stale, nil
}
func checkFiles(outDir string, files []outputFile, stale []string) error {
var problems []string
for _, f := range files {
current, err := os.ReadFile(filepath.Join(outDir, f.path))
if err != nil || !bytes.Equal(current, []byte(f.content)) {
problems = append(problems, "outdated: "+f.path)
}
}
for _, s := range stale {
problems = append(problems, "stale: "+s)
}
if len(problems) > 0 {
return fmt.Errorf("generated logos are out of date; run task logos:gen\n %s",
strings.Join(problems, "\n "))
}
return nil
}
func writeFiles(outDir string, files []outputFile, stale []string) error {
for _, f := range files {
path := filepath.Join(outDir, f.path)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
if err := os.WriteFile(path, []byte(f.content), 0o644); err != nil {
return err
}
}
for _, s := range stale {
if err := os.Remove(filepath.Join(outDir, s)); err != nil {
return err
}
fmt.Printf("[DEL] %s\n", s)
}
return nil
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "genlogo: "+format+"\n", args...)
os.Exit(1)
}
package main
import "image/color"
// art is a glyph grid in row-major order. Every row has cols cells.
type art struct {
cols, rows int
cells []artCell
}
// artCell is one terminal cell. A cell with tinted false uses the terminal's
// default foreground.
type artCell struct {
glyph byte
color color.NRGBA
tinted bool
}
func newArt(cols, rows int) art {
cells := make([]artCell, cols*rows)
for i := range cells {
cells[i].glyph = ' '
}
return art{cols: cols, rows: rows, cells: cells}
}
func (a art) at(col, row int) artCell {
return a.cells[row*a.cols+col]
}
func (a art) set(col, row int, c artCell) {
a.cells[row*a.cols+col] = c
}
package main
import (
"fmt"
"image/color"
"math"
"strconv"
"strings"
)
// oklab is a color in the OKLab perceptual space. Euclidean distance in it
// approximates perceived color difference.
type oklab struct {
l, a, b float64
}
func srgbToLinear(v uint8) float64 {
c := float64(v) / 255
if c <= 0.04045 {
return c / 12.92
}
return math.Pow((c+0.055)/1.055, 2.4)
}
func toOKLab(c color.NRGBA) oklab {
r, g, b := srgbToLinear(c.R), srgbToLinear(c.G), srgbToLinear(c.B)
l := math.Cbrt(0.4122214708*r + 0.5363325363*g + 0.0514459929*b)
m := math.Cbrt(0.2119034982*r + 0.6806995451*g + 0.1073969566*b)
s := math.Cbrt(0.0883024619*r + 0.2817188376*g + 0.6299787005*b)
return oklab{
l: 0.2104542553*l + 0.7936177850*m - 0.0040720468*s,
a: 1.9779984951*l - 2.4285922050*m + 0.4505937099*s,
b: 0.0259040371*l + 0.7827717662*m - 0.8086757660*s,
}
}
func (o oklab) distance2(p oklab) float64 {
dl, da, db := o.l-p.l, o.a-p.a, o.b-p.b
return dl*dl + da*da + db*db
}
func (o oklab) chroma() float64 {
return math.Hypot(o.a, o.b)
}
// parseHexColor accepts "#rrggbb" and "rrggbb".
func parseHexColor(s string) (color.NRGBA, error) {
hex := strings.TrimPrefix(s, "#")
if len(hex) != 6 {
return color.NRGBA{}, fmt.Errorf("color %q is not #rrggbb", s)
}
v, err := strconv.ParseUint(hex, 16, 32)
if err != nil {
return color.NRGBA{}, fmt.Errorf("color %q is not #rrggbb", s)
}
return color.NRGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 255}, nil
}
func linearToSRGB(v float64) uint8 {
v = min(max(v, 0), 1)
if v <= 0.0031308 {
v *= 12.92
} else {
v = 1.055*math.Pow(v, 1/2.4) - 0.055
}
return uint8(math.Round(v * 255))
}
// toSRGB clips colors outside the sRGB gamut per channel.
func (o oklab) toSRGB() color.NRGBA {
l := o.l + 0.3963377774*o.a + 0.2158037573*o.b
m := o.l - 0.1055613458*o.a - 0.0638541728*o.b
s := o.l - 0.0894841775*o.a - 1.2914855480*o.b
l, m, s = l*l*l, m*m*m, s*s*s
return color.NRGBA{
R: linearToSRGB(4.0767416621*l - 3.3077115913*m + 0.2309699292*s),
G: linearToSRGB(-1.2684380046*l + 2.6097574011*m - 0.3413193965*s),
B: linearToSRGB(-0.0041960863*l - 0.7034186147*m + 1.7076147010*s),
A: 255,
}
}
// Terminal themes can be dark or light, so logo colors must stay readable
// on both. Gray colors take the terminal foreground, which every theme makes
// readable. Other colors keep their hue with lightness held in a middle band.
const (
grayChroma = 0.02
terminalLightMin = 0.5
terminalLightMax = 0.8
)
// terminalColor returns the foreground to draw a palette color with, and
// false when the terminal default foreground fits better.
func terminalColor(c color.NRGBA) (color.NRGBA, bool) {
o := toOKLab(c)
if o.chroma() < grayChroma {
return color.NRGBA{}, false
}
o.l = min(max(o.l, terminalLightMin), terminalLightMax)
return o.toSRGB(), true
}
func hexColor(c color.NRGBA) string {
return fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B)
}
package main
import (
"fmt"
"image/color"
"strings"
)
// plainText renders the art without escape sequences. It keeps leading
// spaces, which position the glyphs, and drops trailing ones.
func plainText(a art) string {
var sb strings.Builder
for row := range a.rows {
end := rowEnd(a, row)
for col := range end {
sb.WriteByte(a.at(col, row).glyph)
}
sb.WriteByte('\n')
}
return sb.String()
}
// ansiText renders the art with 24-bit SGR foreground colors. It emits a
// color only where it changes and resets at the end of each row, so every
// line is self-contained.
func ansiText(a art) string {
var sb strings.Builder
for row := range a.rows {
end := rowEnd(a, row)
var current *color.NRGBA
tinted := false
for col := range end {
cell := a.at(col, row)
if cell.glyph == ' ' {
sb.WriteByte(' ')
continue
}
switch {
case cell.tinted && (current == nil || *current != cell.color):
fmt.Fprintf(&sb, "\x1b[38;2;%d;%d;%dm", cell.color.R, cell.color.G, cell.color.B)
c := cell.color
current = &c
tinted = true
case !cell.tinted && current != nil:
sb.WriteString("\x1b[39m")
current = nil
}
sb.WriteByte(cell.glyph)
}
if tinted {
sb.WriteString("\x1b[0m")
}
sb.WriteByte('\n')
}
return sb.String()
}
func rowEnd(a art, row int) int {
end := a.cols
for end > 0 && a.at(end-1, row).glyph == ' ' {
end--
}
return end
}
package main
import (
"fmt"
"os"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
)
// loadCellFont parses the monospace font that glyph shapes are measured with
// and previews are drawn with.
func loadCellFont(path string) (*opentype.Font, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
f, err := opentype.Parse(data)
if err != nil {
return nil, fmt.Errorf("parse font %s: %w", path, err)
}
return f, nil
}
// cellFace returns a face of the monospace font f whose glyph advance is
// cellWidth pixels.
func cellFace(f *opentype.Font, cellWidth int) (font.Face, error) {
const probeSize = 1000
probe, err := opentype.NewFace(f, &opentype.FaceOptions{Size: probeSize, DPI: 72})
if err != nil {
return nil, err
}
advance, ok := probe.GlyphAdvance('M')
if !ok || advance <= 0 {
return nil, fmt.Errorf("font has no advance for 'M'")
}
em := float64(advance) / 64 / probeSize
return opentype.NewFace(f, &opentype.FaceOptions{
Size: float64(cellWidth) / em,
DPI: 72,
Hinting: font.HintingNone,
})
}
// cellBaseline centers ascent and descent vertically in a cell, so that
// descenders and tall glyphs stay inside it.
func cellBaseline(face font.Face, cellHeight int) fixed.Int26_6 {
m := face.Metrics()
leading := fixed.I(cellHeight) - m.Ascent - m.Descent
return leading/2 + m.Ascent
}
package main
import (
"image"
"math"
"golang.org/x/image/font"
"golang.org/x/image/math/fixed"
)
// glyphCellWidth is the raster width of one glyph cell. Cells are twice as
// tall as wide, which is the usual terminal cell shape.
const (
glyphCellWidth = 24
glyphCellHeight = 2 * glyphCellWidth
)
// sampleWindow is a rectangle in unit cell coordinates: (0,0) is the top left
// corner of the cell and (1,1) the bottom right.
type sampleWindow struct {
x0, y0, x1, y1 float64
}
// sampleWindows tiles the cell with cols×rows windows. Each window grows by
// overlap times its size on every side and is clipped to the cell. Overlap
// makes a feature respond to ink near its window, not only inside it.
func sampleWindows(cols, rows int, overlap float64) []sampleWindow {
windows := make([]sampleWindow, 0, cols*rows)
w, h := 1/float64(cols), 1/float64(rows)
for row := range rows {
for col := range cols {
x, y := float64(col)*w, float64(row)*h
windows = append(windows, sampleWindow{
x0: max(0, x-overlap*w),
y0: max(0, y-overlap*h),
x1: min(1, x+w+overlap*w),
y1: min(1, y+h+overlap*h),
})
}
}
return windows
}
// glyphSet holds the shape vector of each candidate glyph. Each vector
// component is the glyph's ink coverage in one sample window, divided by the
// largest coverage any glyph has in that window. A fully inked image region
// thus maps to the densest available glyph shape.
type glyphSet struct {
glyphs []byte
windows []sampleWindow
vectors [][]float64
}
func newGlyphSet(face font.Face, glyphs string, windows []sampleWindow) glyphSet {
baseline := cellBaseline(face, glyphCellHeight)
set := glyphSet{
glyphs: []byte(glyphs),
windows: windows,
vectors: make([][]float64, len(glyphs)),
}
scale := make([]float64, len(windows))
for i, g := range set.glyphs {
coverage := rasterizeGlyph(face, baseline, g)
v := make([]float64, len(windows))
for k, w := range windows {
v[k] = coverage.mean(
w.x0*glyphCellWidth, w.y0*glyphCellHeight,
w.x1*glyphCellWidth, w.y1*glyphCellHeight,
)
scale[k] = max(scale[k], v[k])
}
set.vectors[i] = v
}
for _, v := range set.vectors {
for k := range v {
if scale[k] > 0 {
v[k] /= scale[k]
}
}
}
return set
}
// fillRamp holds the glyphs that fill uniform regions, with their ink
// density relative to the densest of them.
type fillRamp struct {
glyphs []byte
density []float64
}
func newFillRamp(face font.Face, glyphs string) fillRamp {
baseline := cellBaseline(face, glyphCellHeight)
ramp := fillRamp{glyphs: []byte(glyphs), density: make([]float64, len(glyphs))}
densest := 0.0
for i, g := range ramp.glyphs {
coverage := rasterizeGlyph(face, baseline, g)
ramp.density[i] = coverage.mean(0, 0, glyphCellWidth, glyphCellHeight)
densest = max(densest, ramp.density[i])
}
for i := range ramp.density {
ramp.density[i] /= densest
}
return ramp
}
// pick returns the glyph whose relative density is nearest to tone, or a
// space when tone is nearer to no ink.
func (r fillRamp) pick(tone float64) byte {
best, bestDistance := byte(' '), tone
for i, d := range r.density {
if distance := math.Abs(d - tone); distance < bestDistance {
best, bestDistance = r.glyphs[i], distance
}
}
return best
}
func rasterizeGlyph(face font.Face, baseline fixed.Int26_6, g byte) plane {
mask := image.NewAlpha(image.Rect(0, 0, glyphCellWidth, glyphCellHeight))
d := font.Drawer{
Dst: mask,
Src: image.Opaque,
Face: face,
Dot: fixed.Point26_6{Y: baseline},
}
d.DrawString(string(g))
values := make([]float64, glyphCellWidth*glyphCellHeight)
for i, a := range mask.Pix {
values[i] = float64(a) / 255
}
return newPlane(glyphCellWidth, glyphCellHeight, values)
}
// match returns the index of the glyph whose shape vector is nearest to v.
// Ties resolve to the earlier glyph, so output is deterministic.
func (s glyphSet) match(v []float64) int {
best, bestDistance := 0, -1.0
for i, g := range s.vectors {
d := 0.0
for k, x := range v {
diff := x - g[k]
d += diff * diff
}
if bestDistance < 0 || d < bestDistance {
best, bestDistance = i, d
}
}
return best
}
package main
import (
"fmt"
"image"
"image/color"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// manifest pins every input of the generated logos: the font that glyph
// shapes are measured with, the art sizes, and one upstream image per logo.
type manifest struct {
Font source `yaml:"font"`
Variants []variantSpec `yaml:"variants"`
Logos []logoSpec `yaml:"logos"`
}
// variantSpec is one art size. Art fills the box on its limiting axis.
type variantSpec struct {
Name string `yaml:"name"`
Cols int `yaml:"cols"`
Rows int `yaml:"rows"`
}
type logoSpec struct {
ID string `yaml:"id"`
Source source `yaml:"source"`
// Crop is x0, y0, x1, y1 in source pixels. For SVG, pixels of the
// raster at svgRasterSize.
Crop []int `yaml:"crop,omitempty"`
// Blur smooths the source with this radius in source pixels before the
// palette is taken. It turns fine texture into the shading of larger
// faces, which reads better at terminal size.
Blur int `yaml:"blur,omitempty"`
// Knockout clears the background that touches the image border. The
// value is the largest OKLab step between neighbor pixels that still
// belongs to the background, so gradients clear too.
Knockout float64 `yaml:"knockout,omitempty"`
// Pixel marks upscaled pixel art. Cells then align to art pixels.
Pixel bool `yaml:"pixel,omitempty"`
// Flat gives every color full ink. The plain art is then a silhouette,
// and only color tells regions apart. This suits gradients, which
// otherwise turn into a noisy mix of fill glyphs.
Flat bool `yaml:"flat,omitempty"`
// Glyphs names the glyph style of shape edges: solid (the default) or
// line.
Glyphs string `yaml:"glyphs,omitempty"`
// Despeckle clears ink islands smaller than this share of all ink, such
// as lettering around a badge.
Despeckle float64 `yaml:"despeckle,omitempty"`
// Colors is the k-means palette size when Palette is empty.
Colors int `yaml:"colors,omitempty"`
Palette []paletteSpec `yaml:"palette,omitempty"`
}
// paletteSpec fixes one palette color. Tone is its ink density from 0 to 1;
// 0 makes the color background. Without a tone, density follows lightness.
type paletteSpec struct {
Color string `yaml:"color"`
Tone *float64 `yaml:"tone,omitempty"`
}
const defaultPaletteColors = 4
func loadManifest(path string) (manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return manifest{}, err
}
var m manifest
if err := yaml.Unmarshal(data, &m); err != nil {
return manifest{}, fmt.Errorf("parse %s: %w", path, err)
}
if err := m.validate(); err != nil {
return manifest{}, fmt.Errorf("%s: %w", path, err)
}
return m, nil
}
func (m manifest) validate() error {
if len(m.Variants) == 0 {
return fmt.Errorf("no variants")
}
for _, v := range m.Variants {
if !filepath.IsLocal(v.Name) || v.Cols <= 0 || v.Rows <= 0 {
return fmt.Errorf("variant %q: need a plain name and positive cols and rows", v.Name)
}
}
seen := map[string]bool{}
for _, logo := range m.Logos {
if !filepath.IsLocal(logo.ID) || filepath.Base(logo.ID) != logo.ID {
return fmt.Errorf("logo %q: id must be a plain file name", logo.ID)
}
if seen[logo.ID] {
return fmt.Errorf("logo %q: duplicate id", logo.ID)
}
seen[logo.ID] = true
if logo.Crop != nil &&
(len(logo.Crop) != 4 || logo.Crop[0] >= logo.Crop[2] || logo.Crop[1] >= logo.Crop[3]) {
return fmt.Errorf("logo %q: crop must be [x0, y0, x1, y1] with x0 < x1 and y0 < y1", logo.ID)
}
if logo.Colors < 0 || logo.Knockout < 0 || logo.Blur < 0 {
return fmt.Errorf("logo %q: colors, knockout, and blur must not be negative", logo.ID)
}
if logo.Despeckle < 0 || logo.Despeckle >= 1 {
return fmt.Errorf("logo %q: despeckle must be at least 0 and below 1", logo.ID)
}
if _, ok := glyphStyles[logo.Glyphs]; logo.Glyphs != "" && !ok {
return fmt.Errorf("logo %q: unknown glyph style %q", logo.ID, logo.Glyphs)
}
for _, p := range logo.Palette {
if _, err := parseHexColor(p.Color); err != nil {
return fmt.Errorf("logo %q: %w", logo.ID, err)
}
if p.Tone != nil && (*p.Tone < 0 || *p.Tone > 1) {
return fmt.Errorf("logo %q: tone of %s must be within 0 and 1", logo.ID, p.Color)
}
}
}
return nil
}
// paletteAndTones returns the fixed palette of the logo when it has one, and
// otherwise the k-means palette of img, with the ink tone of each color.
// Colors without a fixed tone spread over the tone range by lightness.
func (logo logoSpec) paletteAndTones(img *image.NRGBA) (palette, []float64) {
if len(logo.Palette) == 0 {
k := logo.Colors
if k == 0 {
k = defaultPaletteColors
}
pal := extractPalette(img, k)
return pal, logo.defaultTones(pal.lab)
}
colors := make([]color.NRGBA, len(logo.Palette))
fixed := map[color.NRGBA]float64{}
for i, p := range logo.Palette {
colors[i], _ = parseHexColor(p.Color)
if p.Tone != nil {
fixed[colors[i]] = *p.Tone
}
}
pal := newPalette(colors)
var free []int
var freeLab []oklab
tones := make([]float64, len(pal.colors))
for i, c := range pal.colors {
if tone, ok := fixed[c]; ok {
tones[i] = tone
continue
}
free = append(free, i)
freeLab = append(freeLab, pal.lab[i])
}
for j, tone := range logo.defaultTones(freeLab) {
tones[free[j]] = tone
}
return pal, tones
}
func (logo logoSpec) defaultTones(colors []oklab) []float64 {
if !logo.Flat {
return lightnessTones(colors, toneFloor)
}
tones := make([]float64, len(colors))
for i := range tones {
tones[i] = 1
}
return tones
}
package main
import (
"image"
"image/color"
"slices"
)
// palette holds the flat colors that a logo is reduced to, sorted from dark
// to light.
type palette struct {
colors []color.NRGBA
lab []oklab
}
func newPalette(colors []color.NRGBA) palette {
p := palette{colors: slices.Clone(colors)}
slices.SortStableFunc(p.colors, func(x, y color.NRGBA) int {
lx, ly := toOKLab(x).l, toOKLab(y).l
switch {
case lx < ly:
return -1
case lx > ly:
return 1
default:
return 0
}
})
p.lab = make([]oklab, len(p.colors))
for i, c := range p.colors {
p.lab[i] = toOKLab(c)
}
return p
}
func (p palette) nearest(o oklab) int {
best, bestDistance := 0, -1.0
for i, lab := range p.lab {
if d := o.distance2(lab); bestDistance < 0 || d < bestDistance {
best, bestDistance = i, d
}
}
return best
}
// colorBin accumulates the opaque pixels that share one quantized color.
type colorBin struct {
weight float64
r, g, b float64
lab oklab
cluster int
}
// paletteMinShare drops clusters that cover less than this share of the
// opaque area. Such clusters are usually antialiasing between two flat colors.
const paletteMinShare = 0.01
// extractPalette reduces the opaque pixels of img to at most k flat colors
// with weighted k-means in OKLab. Each output color is the most frequent
// source color of its cluster, so flat brand colors stay exact.
func extractPalette(img *image.NRGBA, k int) palette {
bins := histogram(img)
if len(bins) == 0 || k <= 0 {
return palette{}
}
k = min(k, len(bins))
centers := seedCenters(bins, k)
for range 64 {
changed := assignClusters(bins, centers)
centers = updateCenters(bins, centers)
if !changed {
break
}
}
centers = dropMinorCenters(bins, centers)
assignClusters(bins, centers)
medoids := make([]*colorBin, len(centers))
for i := range bins {
bin := &bins[i]
if m := medoids[bin.cluster]; m == nil || bin.weight > m.weight {
medoids[bin.cluster] = bin
}
}
colors := make([]color.NRGBA, 0, len(medoids))
for _, m := range medoids {
if m == nil {
continue
}
colors = append(colors, color.NRGBA{
R: uint8(m.r/m.weight + 0.5),
G: uint8(m.g/m.weight + 0.5),
B: uint8(m.b/m.weight + 0.5),
A: 255,
})
}
return newPalette(colors)
}
// histogram groups pixels with alpha of at least one half by color quantized
// to 5 bits per channel. Weights are alpha, so faint edges count less.
func histogram(img *image.NRGBA) []colorBin {
index := map[uint16]int{}
var bins []colorBin
b := img.Bounds()
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
c := img.NRGBAAt(x, y)
if c.A < 128 {
continue
}
key := uint16(c.R>>3)<<10 | uint16(c.G>>3)<<5 | uint16(c.B>>3)
i, ok := index[key]
if !ok {
i = len(bins)
index[key] = i
bins = append(bins, colorBin{})
}
w := float64(c.A) / 255
bins[i].weight += w
bins[i].r += w * float64(c.R)
bins[i].g += w * float64(c.G)
bins[i].b += w * float64(c.B)
}
}
for i := range bins {
bin := &bins[i]
bin.lab = toOKLab(color.NRGBA{
R: uint8(bin.r/bin.weight + 0.5),
G: uint8(bin.g/bin.weight + 0.5),
B: uint8(bin.b/bin.weight + 0.5),
A: 255,
})
}
// Map iteration order is random; sort so that seeding is reproducible.
slices.SortFunc(bins, func(x, y colorBin) int {
switch {
case x.weight > y.weight:
return -1
case x.weight < y.weight:
return 1
}
return cmpLab(x.lab, y.lab)
})
return bins
}
func cmpLab(x, y oklab) int {
for _, d := range [3]float64{x.l - y.l, x.a - y.a, x.b - y.b} {
if d < 0 {
return -1
}
if d > 0 {
return 1
}
}
return 0
}
// seedCenters is a deterministic k-means++: it starts at the heaviest color
// and then takes the color with the largest weight times squared distance to
// the chosen centers.
func seedCenters(bins []colorBin, k int) []oklab {
centers := []oklab{bins[0].lab}
nearest := make([]float64, len(bins))
for i := range bins {
nearest[i] = bins[i].lab.distance2(centers[0])
}
for len(centers) < k {
best, bestScore := -1, 0.0
for i := range bins {
if score := bins[i].weight * nearest[i]; score > bestScore {
best, bestScore = i, score
}
}
if best < 0 {
break
}
centers = append(centers, bins[best].lab)
for i := range bins {
nearest[i] = min(nearest[i], bins[i].lab.distance2(bins[best].lab))
}
}
return centers
}
func assignClusters(bins []colorBin, centers []oklab) bool {
p := palette{lab: centers}
changed := false
for i := range bins {
c := p.nearest(bins[i].lab)
if c != bins[i].cluster {
bins[i].cluster = c
changed = true
}
}
return changed
}
func updateCenters(bins []colorBin, centers []oklab) []oklab {
sums := make([]oklab, len(centers))
weights := make([]float64, len(centers))
for _, bin := range bins {
s := &sums[bin.cluster]
s.l += bin.weight * bin.lab.l
s.a += bin.weight * bin.lab.a
s.b += bin.weight * bin.lab.b
weights[bin.cluster] += bin.weight
}
next := make([]oklab, len(centers))
for i, w := range weights {
if w == 0 {
next[i] = centers[i]
continue
}
next[i] = oklab{l: sums[i].l / w, a: sums[i].a / w, b: sums[i].b / w}
}
return next
}
func dropMinorCenters(bins []colorBin, centers []oklab) []oklab {
weights := make([]float64, len(centers))
total := 0.0
for _, bin := range bins {
weights[bin.cluster] += bin.weight
total += bin.weight
}
kept := centers[:0:0]
for i, c := range centers {
if weights[i] >= paletteMinShare*total {
kept = append(kept, c)
}
}
if len(kept) == 0 {
return centers
}
return kept
}
package main
// plane is a summed-area table over a scalar image. It gives exact box
// filter integrals over rectangles with fractional corners, which treats
// pixels as constant squares. Areas outside the image add nothing.
type plane struct {
w, h int
sum []float64
}
// newPlane builds the table from row-major values of a w×h image.
func newPlane(w, h int, values []float64) plane {
p := plane{w: w, h: h, sum: make([]float64, (w+1)*(h+1))}
stride := w + 1
for y := range h {
row := 0.0
for x := range w {
row += values[y*w+x]
p.sum[(y+1)*stride+x+1] = p.sum[y*stride+x+1] + row
}
}
return p
}
// cumulative is the integral over [0,x)×[0,y). It is bilinear inside each
// pixel, so interpolating the table corners is exact.
func (p plane) cumulative(x, y float64) float64 {
x = min(max(x, 0), float64(p.w))
y = min(max(y, 0), float64(p.h))
x0, y0 := int(x), int(y)
x1, y1 := min(x0+1, p.w), min(y0+1, p.h)
fx, fy := x-float64(x0), y-float64(y0)
stride := p.w + 1
s00 := p.sum[y0*stride+x0]
s10 := p.sum[y0*stride+x1]
s01 := p.sum[y1*stride+x0]
s11 := p.sum[y1*stride+x1]
top := s00 + (s10-s00)*fx
bottom := s01 + (s11-s01)*fx
return top + (bottom-top)*fy
}
// mean is the average value over [x0,x1)×[y0,y1), counting the part outside
// the image as zero.
func (p plane) mean(x0, y0, x1, y1 float64) float64 {
area := (x1 - x0) * (y1 - y0)
if area <= 0 {
return 0
}
total := p.cumulative(x1, y1) - p.cumulative(x0, y1) -
p.cumulative(x1, y0) + p.cumulative(x0, y0)
return total / area
}
package main
import (
"fmt"
"image"
"image/color"
stddraw "image/draw"
"golang.org/x/image/draw"
)
const (
// svgRasterSize is the longer side of rasterized SVG sources.
svgRasterSize = 1024
// workingSize caps the longer side of raster sources other than pixel
// art. At the largest variant it still gives each cell more than
// ten source pixels per side.
workingSize = 1024
)
// prepare crops, clears the background, smooths, and scales the source as
// the logo asks. The result always starts at the origin.
func prepare(img *image.NRGBA, logo logoSpec) (*image.NRGBA, error) {
if logo.Crop != nil {
r := image.Rect(logo.Crop[0], logo.Crop[1], logo.Crop[2], logo.Crop[3])
if !r.In(img.Bounds()) {
return nil, fmt.Errorf("crop %v is outside the source bounds %v", r, img.Bounds())
}
img = rebase(img.SubImage(r).(*image.NRGBA))
}
if logo.Knockout > 0 {
knockout(img, logo.Knockout)
}
if logo.Blur > 0 {
blur(img, logo.Blur)
}
if !logo.Pixel {
img = downscale(img, workingSize)
}
return img, nil
}
func rebase(img *image.NRGBA) *image.NRGBA {
out := image.NewNRGBA(image.Rect(0, 0, img.Bounds().Dx(), img.Bounds().Dy()))
stddraw.Draw(out, out.Bounds(), img, img.Bounds().Min, stddraw.Src)
return out
}
// knockout makes transparent every pixel reachable from the image border
// through steps between neighbors of at most tolerance in OKLab.
func knockout(img *image.NRGBA, tolerance float64) {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
limit := tolerance * tolerance
cleared := make([]bool, w*h)
lab := func(x, y int) oklab {
c := img.NRGBAAt(x, y)
return toOKLab(color.NRGBA{R: c.R, G: c.G, B: c.B, A: 255})
}
var queue []image.Point
for x := range w {
queue = append(queue, image.Pt(x, 0), image.Pt(x, h-1))
}
for y := range h {
queue = append(queue, image.Pt(0, y), image.Pt(w-1, y))
}
for _, p := range queue {
cleared[p.Y*w+p.X] = true
}
for len(queue) > 0 {
p := queue[len(queue)-1]
queue = queue[:len(queue)-1]
from := lab(p.X, p.Y)
transparent := img.NRGBAAt(p.X, p.Y).A == 0
for _, d := range [4]image.Point{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {
n := p.Add(d)
if n.X < 0 || n.Y < 0 || n.X >= w || n.Y >= h || cleared[n.Y*w+n.X] {
continue
}
next := img.NRGBAAt(n.X, n.Y)
if next.A != 0 && (transparent || from.distance2(lab(n.X, n.Y)) > limit) {
continue
}
cleared[n.Y*w+n.X] = true
queue = append(queue, n)
}
}
for i, c := range cleared {
if c {
img.Pix[i*4+3] = 0
}
}
}
// downscale shrinks img so that its longer side is at most size. The
// bilinear kernel widens with the scale factor, so it averages every source
// pixel instead of sampling a few.
func downscale(img *image.NRGBA, size int) *image.NRGBA {
w, h := img.Bounds().Dx(), img.Bounds().Dy()
if max(w, h) <= size {
return img
}
scale := float64(size) / float64(max(w, h))
dst := image.NewRGBA(image.Rect(
0, 0,
max(1, int(float64(w)*scale+0.5)),
max(1, int(float64(h)*scale+0.5)),
))
draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil)
return straightAlpha(dst)
}
// blur smooths img with two passes of a box filter of the given radius,
// which approximates a Gaussian. It works on premultiplied color, so
// transparent pixels do not darken edges.
func blur(img *image.NRGBA, radius int) {
w, h := img.Bounds().Dx(), img.Bounds().Dy()
channels := make([][]float64, 4)
for c := range channels {
channels[c] = make([]float64, w*h)
}
for i := range w * h {
a := float64(img.Pix[i*4+3]) / 255
channels[3][i] = a
for c := range 3 {
channels[c][i] = float64(img.Pix[i*4+c]) * a
}
}
scratch := make([]float64, max(w, h))
for _, values := range channels {
for range 2 {
for y := range h {
boxBlur(values[y*w:(y+1)*w], 1, radius, scratch[:w])
}
for x := range w {
boxBlur(values[x:], w, radius, scratch[:h])
}
}
}
for i := range w * h {
a := channels[3][i]
img.Pix[i*4+3] = uint8(min(255, a*255+0.5))
if a <= 0 {
continue
}
for c := range 3 {
img.Pix[i*4+c] = uint8(min(255, channels[c][i]/a+0.5))
}
}
}
// boxBlur averages len(scratch) values that lie stride apart in values over
// a window of 2·radius+1. The window shrinks at the ends.
func boxBlur(values []float64, stride, radius int, scratch []float64) {
n := len(scratch)
sum, count := 0.0, 0
for i := range min(radius, n) {
sum += values[i*stride]
count++
}
for i := range n {
if j := i + radius; j < n {
sum += values[j*stride]
count++
}
if j := i - radius - 1; j >= 0 {
sum -= values[j*stride]
count--
}
scratch[i] = sum / float64(count)
}
for i := range n {
values[i*stride] = scratch[i]
}
}
// despeckle clears ink islands smaller than minShare of all ink. Ink is a
// pixel with alpha of at least one half whose palette color has a tone; an
// island is an 8-connected group of ink. Lettering around a badge falls
// apart into such islands, while the mark stays one large island. The
// antialiased fringe of a cleared island goes with it.
func despeckle(img *image.NRGBA, pal palette, tones []float64, minShare float64) {
w, h := img.Bounds().Dx(), img.Bounds().Dy()
ink := make([]bool, w*h)
total := 0
cache := map[color.NRGBA]bool{}
for y := range h {
for x := range w {
c := img.NRGBAAt(x, y)
if c.A < 128 {
continue
}
opaque := color.NRGBA{R: c.R, G: c.G, B: c.B, A: 255}
isInk, ok := cache[opaque]
if !ok {
isInk = tones[pal.nearest(toOKLab(opaque))] > 0
cache[opaque] = isInk
}
if isInk {
ink[y*w+x] = true
total++
}
}
}
neighbors := func(i int, visit func(n int)) {
x, y := i%w, i/w
for ny := max(y-1, 0); ny <= min(y+1, h-1); ny++ {
for nx := max(x-1, 0); nx <= min(x+1, w-1); nx++ {
visit(ny*w + nx)
}
}
}
seen := make([]bool, w*h)
var island, stack []int
for start := range ink {
if !ink[start] || seen[start] {
continue
}
island, stack = island[:0], append(stack[:0], start)
seen[start] = true
for len(stack) > 0 {
i := stack[len(stack)-1]
stack = stack[:len(stack)-1]
island = append(island, i)
neighbors(i, func(n int) {
if ink[n] && !seen[n] {
seen[n] = true
stack = append(stack, n)
}
})
}
if float64(len(island)) >= minShare*float64(total) {
continue
}
for _, i := range island {
neighbors(i, func(n int) {
if n == i || !ink[n] {
img.Pix[n*4+3] = 0
}
})
}
}
}
package main
import (
"image"
"image/color"
"image/draw"
"image/png"
"os"
"path/filepath"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
)
const (
previewCellWidth = 10
previewCellHeight = previewCellWidth * 2
previewGap = 20
)
// previewTheme is the terminal background and default foreground a preview
// simulates.
type previewTheme struct {
background color.NRGBA
foreground color.NRGBA
}
var (
previewDark = previewTheme{
background: color.NRGBA{R: 0x1e, G: 0x1e, B: 0x1e, A: 0xff},
foreground: color.NRGBA{R: 0xd4, G: 0xd4, B: 0xd4, A: 0xff},
}
previewLight = previewTheme{
background: color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff},
foreground: color.NRGBA{R: 0x24, G: 0x29, B: 0x2f, A: 0xff},
}
previewSheetBackground = color.NRGBA{R: 0x80, G: 0x80, B: 0x80, A: 0xff}
previewCaptionColor = color.NRGBA{R: 0x10, G: 0x10, B: 0x10, A: 0xff}
)
// previewer draws art as a terminal with the cell font would show it.
type previewer struct {
face font.Face
baseline fixed.Int26_6
}
func newPreviewer(f *opentype.Font) (previewer, error) {
face, err := cellFace(f, previewCellWidth)
if err != nil {
return previewer{}, err
}
return previewer{face: face, baseline: cellBaseline(face, previewCellHeight)}, nil
}
// render draws the art with a one-cell margin. When colored is false, every
// glyph uses the theme foreground.
func (p previewer) render(a art, theme previewTheme, colored bool) *image.NRGBA {
img := image.NewNRGBA(image.Rect(
0, 0,
(a.cols+2)*previewCellWidth,
(a.rows+2)*previewCellHeight,
))
draw.Draw(img, img.Bounds(), image.NewUniform(theme.background), image.Point{}, draw.Src)
for row := range a.rows {
for col := range a.cols {
cell := a.at(col, row)
if cell.glyph == ' ' {
continue
}
ink := theme.foreground
if colored && cell.tinted {
ink = cell.color
}
d := font.Drawer{
Dst: img,
Src: image.NewUniform(ink),
Face: p.face,
Dot: fixed.Point26_6{
X: fixed.I((col + 1) * previewCellWidth),
Y: fixed.I((row+1)*previewCellHeight) + p.baseline,
},
}
d.DrawString(string(cell.glyph))
}
}
return img
}
// row places images left to right, top-aligned, each under its caption.
func (p previewer) row(images []*image.NRGBA, captions []string) *image.NRGBA {
width := previewGap * max(len(images)-1, 0)
height := 0
for _, img := range images {
width += img.Bounds().Dx()
height = max(height, img.Bounds().Dy())
}
out := image.NewNRGBA(image.Rect(0, 0, width, previewCellHeight+height))
draw.Draw(out, out.Bounds(), image.NewUniform(previewSheetBackground), image.Point{}, draw.Src)
x := 0
for i, img := range images {
if i < len(captions) && captions[i] != "" {
d := font.Drawer{
Dst: out,
Src: image.NewUniform(previewCaptionColor),
Face: p.face,
Dot: fixed.Point26_6{X: fixed.I(x), Y: p.baseline},
}
d.DrawString(captions[i])
}
bounds := img.Bounds().Add(image.Pt(x, previewCellHeight))
draw.Draw(out, bounds, img, image.Point{}, draw.Src)
x += img.Bounds().Dx() + previewGap
}
return out
}
// stack places images top to bottom with a gap.
func stack(images []*image.NRGBA) *image.NRGBA {
width, height := 0, previewGap*max(len(images)-1, 0)
for _, img := range images {
width = max(width, img.Bounds().Dx())
height += img.Bounds().Dy()
}
out := image.NewNRGBA(image.Rect(0, 0, width, height))
draw.Draw(out, out.Bounds(), image.NewUniform(previewSheetBackground), image.Point{}, draw.Src)
y := 0
for _, img := range images {
draw.Draw(out, img.Bounds().Add(image.Pt(0, y)), img, image.Point{}, draw.Src)
y += img.Bounds().Dy() + previewGap
}
return out
}
// writePNG encodes img to path, creating parent directories.
func writePNG(path string, img image.Image) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
file, err := os.Create(path)
if err != nil {
return err
}
if err := png.Encode(file, img); err != nil {
_ = file.Close()
return err
}
return file.Close()
}
package main
import (
"image"
"image/color"
"math"
)
// cellAspect is terminal cell width over height.
const cellAspect = 0.5
// grid maps terminal cells onto source pixels.
type grid struct {
cols, rows int
x0, y0 float64
cellW, cellH float64
}
// fitGrid scales content to fill a box of maxCols×maxRows cells on its
// limiting axis, rounds the other axis to whole cells, and centers it.
func fitGrid(content image.Rectangle, maxCols, maxRows int) grid {
w, h := float64(content.Dx()), float64(content.Dy())
cols, rows := maxCols, maxRows
if fitRows := h * cellAspect * float64(maxCols) / w; fitRows <= float64(maxRows) {
rows = max(1, int(math.Round(fitRows)))
} else {
cols = max(1, int(math.Round(w*float64(maxRows)/(h*cellAspect))))
}
cellW := max(w/float64(cols), h*cellAspect/float64(rows))
cellH := cellW / cellAspect
return grid{
cols: cols,
rows: rows,
x0: float64(content.Min.X) - (float64(cols)*cellW-w)/2,
y0: float64(content.Min.Y) - (float64(rows)*cellH-h)/2,
cellW: cellW,
cellH: cellH,
}
}
// pixelCellRatios lists how many cells one art pixel spans horizontally,
// largest first. Each keeps cells aligned to art pixel edges.
var pixelCellRatios = []float64{4, 3, 2, 1, 0.5}
// fitPixelGrid maps each art pixel of side scale source pixels to whole or
// half cells, so every cell edge lies on an art pixel edge. It reports false
// when even the smallest ratio does not fit the box.
func fitPixelGrid(content image.Rectangle, scale, maxCols, maxRows int) (grid, bool) {
w := float64(content.Dx()) / float64(scale)
h := float64(content.Dy()) / float64(scale)
for _, ratio := range pixelCellRatios {
cols := int(math.Ceil(w * ratio))
rows := int(math.Ceil(h * ratio * cellAspect))
if cols > maxCols || rows > maxRows {
continue
}
cellW := float64(scale) / ratio
return grid{
cols: cols,
rows: rows,
x0: float64(content.Min.X),
y0: float64(content.Min.Y),
cellW: cellW,
cellH: cellW / cellAspect,
}, true
}
return grid{}, false
}
// detectPixelScale returns the side, in source pixels, of the square blocks
// that an upscaled pixel art image is made of, or 1 when it is not pixel art.
func detectPixelScale(img *image.NRGBA, content image.Rectangle) int {
for scale := min(content.Dx(), content.Dy()) / 4; scale >= 2; scale-- {
if content.Dx()%scale != 0 || content.Dy()%scale != 0 {
continue
}
if uniformBlockShare(img, content, scale) >= 0.97 {
return scale
}
}
return 1
}
func uniformBlockShare(img *image.NRGBA, content image.Rectangle, scale int) float64 {
uniform, total := 0, 0
for by := content.Min.Y; by < content.Max.Y; by += scale {
for bx := content.Min.X; bx < content.Max.X; bx += scale {
total++
if blockIsUniform(img, bx, by, scale) {
uniform++
}
}
}
return float64(uniform) / float64(total)
}
func blockIsUniform(img *image.NRGBA, bx, by, scale int) bool {
ref := img.NRGBAAt(bx, by)
for y := by; y < by+scale; y++ {
for x := bx; x < bx+scale; x++ {
c := img.NRGBAAt(x, y)
if ref.A == 0 && c.A == 0 {
continue
}
if channelDiff(ref.R, c.R) > 8 || channelDiff(ref.G, c.G) > 8 ||
channelDiff(ref.B, c.B) > 8 || channelDiff(ref.A, c.A) > 8 {
return false
}
}
}
return true
}
func channelDiff(a, b uint8) int {
if a > b {
return int(a - b)
}
return int(b - a)
}
// inkThreshold is the least ink that counts as content when trimming.
const inkThreshold = 0.03
// inkPlanes holds the source image reduced to its palette. tone is the ink
// density of every pixel; labels[k] is the alpha of pixels that belong to
// palette color k. bounds encloses all pixels with ink.
type inkPlanes struct {
tone plane
labels []plane
bounds image.Rectangle
}
// newInkPlanes expects img to start at the origin. Pixels of a color with
// tone 0 count as background.
func newInkPlanes(img *image.NRGBA, pal palette, tones []float64) inkPlanes {
w, h := img.Bounds().Dx(), img.Bounds().Dy()
tone := make([]float64, w*h)
labels := make([][]float64, len(pal.colors))
for k := range labels {
labels[k] = make([]float64, w*h)
}
bounds := image.Rectangle{}
cache := map[color.NRGBA]int{}
for y := range h {
for x := range w {
c := img.NRGBAAt(x, y)
if c.A == 0 {
continue
}
opaque := color.NRGBA{R: c.R, G: c.G, B: c.B, A: 255}
k, ok := cache[opaque]
if !ok {
k = pal.nearest(toOKLab(opaque))
cache[opaque] = k
}
if tones[k] == 0 {
continue
}
alpha := float64(c.A) / 255
tone[y*w+x] = alpha * tones[k]
labels[k][y*w+x] = alpha
if tone[y*w+x] >= inkThreshold {
bounds = bounds.Union(image.Rect(x, y, x+1, y+1))
}
}
}
planes := inkPlanes{
tone: newPlane(w, h, tone),
labels: make([]plane, len(labels)),
bounds: bounds,
}
for k, values := range labels {
planes.labels[k] = newPlane(w, h, values)
}
return planes
}
// renderOptions tunes how renderArt turns ink into glyphs.
type renderOptions struct {
// uniformity is the largest spread of window ink inside a cell that
// still counts as a uniform region. Such cells take a fill glyph, so
// close tones inside a region do not turn into shape glyphs.
uniformity float64
fill fillRamp
}
// renderArt fills uniform cells from the fill ramp and gives every other
// cell the glyph whose shape best matches its ink. Each glyph takes the
// palette color that lies under its ink.
func renderArt(planes inkPlanes, pal palette, g grid, set glyphSet, opts renderOptions) art {
out := newArt(g.cols, g.rows)
inks := make([]artCell, len(pal.colors))
for i, c := range pal.colors {
inks[i].color, inks[i].tinted = terminalColor(c)
}
v := make([]float64, len(set.windows))
rects := make([][4]float64, len(set.windows))
flat := make([]float64, len(set.windows))
for k := range flat {
flat[k] = 1
}
for row := range g.rows {
for col := range g.cols {
cx := g.x0 + float64(col)*g.cellW
cy := g.y0 + float64(row)*g.cellH
peak, floor, sum := 0.0, math.Inf(1), 0.0
for k, w := range set.windows {
rects[k] = [4]float64{
cx + w.x0*g.cellW, cy + w.y0*g.cellH,
cx + w.x1*g.cellW, cy + w.y1*g.cellH,
}
v[k] = planes.tone.mean(rects[k][0], rects[k][1], rects[k][2], rects[k][3])
peak = max(peak, v[k])
floor = min(floor, v[k])
sum += v[k]
}
if peak == 0 {
continue
}
glyph, weights := byte(0), flat
if peak-floor <= opts.uniformity {
glyph = opts.fill.pick(sum / float64(len(v)))
} else {
i := set.match(v)
glyph, weights = set.glyphs[i], set.vectors[i]
}
if glyph == ' ' {
continue
}
best, bestScore := 0, -1.0
for label, p := range planes.labels {
score := 0.0
for k, r := range rects {
score += weights[k] * p.mean(r[0], r[1], r[2], r[3])
}
if score > bestScore {
best, bestScore = label, score
}
}
cell := inks[best]
cell.glyph = glyph
out.set(col, row, cell)
}
}
return out
}
// toneFloor is the lowest tone that lightnessTones gives. Darker colors
// still need visible ink, or they vanish on dark terminals.
const toneFloor = 0.35
// lightnessTones gives each color an ink density that rises with its OKLab
// lightness from floor to 1. A single color gets full ink.
func lightnessTones(colors []oklab, floor float64) []float64 {
tones := make([]float64, len(colors))
if len(tones) == 0 {
return tones
}
lo, hi := colors[0].l, colors[0].l
for _, c := range colors {
lo, hi = min(lo, c.l), max(hi, c.l)
}
for i, c := range colors {
if hi-lo < 1e-6 {
tones[i] = 1
continue
}
tones[i] = floor + (1-floor)*(c.l-lo)/(hi-lo)
}
return tones
}
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"image"
"image/draw"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"math"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
_ "golang.org/x/image/webp"
)
const (
sourceAttempts = 3
sourceTimeout = 60 * time.Second
sourceAgent = "lucy-genlogo"
)
// source pins one upstream image.
type source struct {
URL string `yaml:"url"`
SHA256 string `yaml:"sha256"`
}
// digestError reports a download whose bytes do not match the pinned digest.
type digestError struct {
url string
got string
want string
}
func (e digestError) Error() string {
return fmt.Sprintf("sha256 mismatch for %s: got %s want %s", e.url, e.got, e.want)
}
// fetchSource returns the path of the cached, digest-verified source file and
// downloads it on a cache miss.
func fetchSource(src source, cacheDir string) (string, error) {
digest, err := normalizeDigest(src.SHA256)
if err != nil {
return "", err
}
path := filepath.Join(cacheDir, digest[:2], digest)
hit, err := hasDigest(path, digest)
if err != nil {
return "", err
}
if hit {
return path, nil
}
// A file with another digest cannot become valid, so drop it.
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("drop stale cache %s: %w", path, err)
}
if err := downloadSource(src.URL, path, digest); err != nil {
return "", err
}
return path, nil
}
// normalizeDigest accepts hex in either case and returns lowercase hex.
func normalizeDigest(raw string) (string, error) {
digest := strings.ToLower(strings.TrimSpace(raw))
if len(digest) != sha256.Size*2 {
return "", fmt.Errorf("sha256 must be 64 hex characters, got %d", len(digest))
}
if _, err := hex.DecodeString(digest); err != nil {
return "", fmt.Errorf("sha256 %q is not hex: %w", raw, err)
}
return digest, nil
}
// hasDigest reports whether path exists with the wanted digest.
func hasDigest(path, want string) (bool, error) {
got, err := fileDigest(path)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, err
}
return got == want, nil
}
func fileDigest(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", fmt.Errorf("hash %s: %w", path, err)
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// downloadSource retries network failures but not a digest mismatch, which
// retrying cannot fix.
func downloadSource(url, path, want string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create cache dir %s: %w", dir, err)
}
client := &http.Client{Timeout: sourceTimeout}
var lastErr error
for range sourceAttempts {
lastErr = downloadOnce(client, url, path, want)
if lastErr == nil {
return nil
}
var bad digestError
if errors.As(lastErr, &bad) {
break
}
}
return lastErr
}
func downloadOnce(client *http.Client, url, path, want string) error {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("build request for %s: %w", url, err)
}
req.Header.Set("User-Agent", sourceAgent)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("download %s: %s", url, resp.Status)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".download-*")
if err != nil {
return fmt.Errorf("create temp file for %s: %w", path, err)
}
tmpName := tmp.Name()
done := false
defer func() {
if done {
return
}
tmp.Close()
os.Remove(tmpName)
}()
hash := sha256.New()
if _, err := io.Copy(io.MultiWriter(tmp, hash), resp.Body); err != nil {
return fmt.Errorf("download %s: %w", url, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("write %s: %w", tmpName, err)
}
got := hex.EncodeToString(hash.Sum(nil))
if got != want {
return digestError{url: url, got: got, want: want}
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("store %s: %w", path, err)
}
done = true
return nil
}
// decodeImage decodes PNG, JPEG, GIF, WebP, or SVG into straight-alpha NRGBA.
// It sniffs the content, not the file name. It rasterizes SVG so that the
// longer side is svgSize pixels.
func decodeImage(path string, svgSize int) (*image.NRGBA, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if isSVG(data) {
img, err := rasterizeSVG(data, svgSize)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return img, nil
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return straightAlpha(img), nil
}
// isSVG reports whether the content, after any BOM and XML preamble, has an
// svg root. Upstream URLs do not always carry an .svg suffix.
func isSVG(data []byte) bool {
rest := bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))
for {
rest = bytes.TrimLeft(rest, " \t\r\n")
var cut bool
switch {
case bytes.HasPrefix(rest, []byte("<?")):
rest, cut = skipPast(rest, []byte("?>"))
case bytes.HasPrefix(rest, []byte("<!--")):
rest, cut = skipPast(rest, []byte("-->"))
case bytes.HasPrefix(rest, []byte("<!DOCTYPE")),
bytes.HasPrefix(rest, []byte("<!doctype")):
rest, cut = skipPast(rest, []byte(">"))
default:
return bytes.Contains(rest, []byte("<svg"))
}
if !cut {
return false
}
}
}
// skipPast returns the content after the first delim, and false when absent.
func skipPast(data, delim []byte) ([]byte, bool) {
idx := bytes.Index(data, delim)
if idx < 0 {
return nil, false
}
return data[idx+len(delim):], true
}
// rasterizeSVG draws data with a transparent background. oksvg and rasterx
// panic on some valid-looking input, so a panic becomes an error.
func rasterizeSVG(data []byte, size int) (img *image.NRGBA, err error) {
if size < 1 {
return nil, fmt.Errorf("svg size %d is below 1 pixel", size)
}
defer func() {
if reason := recover(); reason != nil {
img = nil
err = fmt.Errorf("rasterize svg: %v", reason)
}
}()
icon, err := oksvg.ReadIconStream(bytes.NewReader(data), oksvg.WarnErrorMode)
if err != nil {
return nil, fmt.Errorf("parse svg: %w", err)
}
width, height := svgExtent(data, icon.ViewBox.W, icon.ViewBox.H)
scale := float64(size) / max(width, height)
cols := max(1, int(math.Round(width*scale)))
rows := max(1, int(math.Round(height*scale)))
// Scale before translating: oksvg's SetTarget translates by the unscaled
// viewBox origin, which shifts icons with a non-zero origin.
icon.Transform = rasterx.Identity.Scale(scale, scale).Translate(
-icon.ViewBox.X,
-icon.ViewBox.Y,
)
// oksvg transforms path points before stroking, so line widths and dashes
// stay in user units and would render too thin on a scaled raster.
for i := range icon.SVGPaths {
path := &icon.SVGPaths[i]
path.LineWidth *= scale
for j := range path.Dash {
path.Dash[j] *= scale
}
}
canvas := image.NewRGBA(image.Rect(0, 0, cols, rows))
scanner := rasterx.NewScannerGV(cols, rows, canvas, canvas.Bounds())
icon.Draw(rasterx.NewDasher(cols, rows, scanner), 1)
return straightAlpha(canvas), nil
}
// svgExtent falls back to the root width and height when the viewBox has no
// positive extent. Without either, the drawing is assumed square.
func svgExtent(data []byte, width, height float64) (float64, float64) {
if width > 0 && height > 0 {
return width, height
}
attrW, attrH := rootLengths(data)
if width <= 0 {
width = attrW
}
if height <= 0 {
height = attrH
}
if width <= 0 || height <= 0 {
return 1, 1
}
return width, height
}
func rootLengths(data []byte) (width, height float64) {
decoder := xml.NewDecoder(bytes.NewReader(data))
for {
token, err := decoder.Token()
if err != nil {
return 0, 0
}
start, ok := token.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != "svg" {
return 0, 0
}
for _, attr := range start.Attr {
switch attr.Name.Local {
case "width":
width = lengthPixels(attr.Value)
case "height":
height = lengthPixels(attr.Value)
}
}
return width, height
}
}
// lengthPixels parses a length as user units. Only the width to height ratio
// matters, so physical units are not converted.
func lengthPixels(value string) float64 {
value = strings.TrimSpace(value)
for _, unit := range []string{"px", "pt", "pc", "mm", "cm", "in", "em", "ex", "%"} {
value = strings.TrimSuffix(value, unit)
}
length, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil {
return 0
}
return length
}
// straightAlpha converts any image to non-premultiplied NRGBA. draw.Draw
// performs the unpremultiply step when the destination is NRGBA.
func straightAlpha(src image.Image) *image.NRGBA {
if img, ok := src.(*image.NRGBA); ok {
return img
}
bounds := src.Bounds()
dst := image.NewNRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy()))
draw.Draw(dst, dst.Bounds(), src, bounds.Min, draw.Src)
return dst
}
package main
import (
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"os"
"reflect"
"github.com/mclucy/lucy/java"
stdjson "encoding/json"
"github.com/invopop/jsonschema"
"github.com/mclucy/lucy/document"
orderedmap "github.com/pb33f/ordered-map/v2"
)
const schemaURL = "https://lucy.lc/manifest.schema.json"
func main() {
requirementSchema := &jsonschema.Schema{
OneOf: []*jsonschema.Schema{
{
Type: "string",
Description: "A version selector: any, stable, beta, a tag, or an exact version.",
},
func() *jsonschema.Schema {
selector := &jsonschema.Schema{
Type: "string",
Description: "A version selector: any, stable, beta, a tag, or an exact version.",
}
filePattern := &jsonschema.Schema{
Type: "string",
Description: "Glob pattern matching the upstream artifact filename.",
}
enabled := &jsonschema.Schema{
Type: "boolean",
Default: true,
Description: "Whether the requirement is active in the resolved environment.",
}
properties := orderedmap.New[string, *jsonschema.Schema]()
properties.Set("selector", selector)
properties.Set("file_pattern", filePattern)
properties.Set("enabled", enabled)
return &jsonschema.Schema{
Type: "object",
Properties: properties,
AdditionalProperties: jsonschema.FalseSchema,
}
}(),
},
}
trackedFileSchema := func() *jsonschema.Schema {
path := &jsonschema.Schema{
Type: "string",
Description: "Workspace-relative forward-slash path to the tracked file.",
}
immutable := &jsonschema.Schema{
Type: "boolean",
Default: false,
Description: "Whether deviation from the accepted digest produces an immutable-file warning.",
}
hash := &jsonschema.Schema{
Type: "string",
Pattern: `^sha256:[0-9a-f]{64}$`,
Description: "Accepted SHA-256 digest in canonical sha256:<hex> form.",
}
recoverURL := &jsonschema.Schema{
Type: "string",
Format: "uri",
Pattern: `^https?://`,
Description: "Remote HTTP(S) URL used to restore missing file contents.",
}
properties := orderedmap.New[string, *jsonschema.Schema]()
properties.Set("path", path)
properties.Set("immutable", immutable)
properties.Set("hash", hash)
properties.Set("recover_url", recoverURL)
return &jsonschema.Schema{
Type: "object",
Properties: properties,
Required: []string{"path"},
AdditionalProperties: jsonschema.FalseSchema,
AllOf: []*jsonschema.Schema{
{
If: &jsonschema.Schema{
Properties: func() *orderedmap.OrderedMap[string, *jsonschema.Schema] {
p := orderedmap.New[string, *jsonschema.Schema]()
p.Set("immutable", &jsonschema.Schema{Const: true})
return p
}(),
Required: []string{"immutable"},
},
Then: &jsonschema.Schema{
Required: []string{"hash"},
},
},
{
If: &jsonschema.Schema{
Required: []string{"recover_url"},
},
Then: &jsonschema.Schema{
Required: []string{"hash"},
},
},
},
}
}()
javaSchema := func() *jsonschema.Schema {
major := func() *jsonschema.Schema {
return &jsonschema.Schema{
Type: "integer",
Minimum: stdjson.Number(fmt.Sprint(document.MinimumJava)),
}
}
version := &jsonschema.Schema{
OneOf: []*jsonschema.Schema{
major(),
{Const: document.JavaAuto},
},
Description: "The Java major version, or auto.",
}
distributions := []any{document.JavaAuto, "any"}
for _, distribution := range java.Distributions {
distributions = append(distributions, string(distribution))
}
distribution := &jsonschema.Schema{
Enum: distributions,
Description: "The Java distribution. Omit it to accept any distribution.",
}
properties := orderedmap.New[string, *jsonschema.Schema]()
properties.Set("version", version)
properties.Set("distribution", distribution)
short := major()
short.Description = "The Java major version."
return &jsonschema.Schema{
OneOf: []*jsonschema.Schema{
short,
{
Const: document.JavaAuto,
Description: "Follow the Java version Mojang recommends.",
},
{
Type: "object",
Properties: properties,
AdditionalProperties: jsonschema.FalseSchema,
},
},
}
}()
r := &jsonschema.Reflector{
DoNotReference: true,
BaseSchemaID: jsonschema.ID(schemaURL),
Mapper: func(t reflect.Type) *jsonschema.Schema {
switch t {
case reflect.TypeFor[document.Requirement]():
return requirementSchema
case reflect.TypeFor[document.ManifestJava]():
return javaSchema
case reflect.TypeFor[document.TrackedFile]():
return trackedFileSchema
default:
return nil
}
},
}
schema := r.Reflect(&document.Manifest{})
schema.Version = jsonschema.Version
schema.ID = schemaURL
schema.Title = "Lucy manifest"
schema.Description = "Desired-state manifest (lucy.yaml) for a Lucy-managed Minecraft server."
raw, err := json.Marshal(schema, jsontext.Multiline(true), jsontext.WithIndent(" "))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
raw = append(raw, '\n')
path := "assets/public/manifest.schema.json"
if err := os.WriteFile(path, raw, 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("regenerated", path)
}
// Command genwiki renders the documentation site as GitHub wiki pages.
//
// GitHub wikis store every page as a flat Markdown file in one Git repository.
// The site navigation defines which pages exist and in what order; the page
// title defines the file name. Links between site pages are rewritten to the
// flat wiki page names.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
)
const (
homeSlug = "Home"
docsSite = "https://lucy.lc/docs"
docsRepo = "https://github.com/mclucy/lucy/tree/main/docs"
)
type siteConfig struct {
Navigation struct {
Pages []json.RawMessage `json:"pages"`
} `json:"navigation"`
}
type section struct {
Name string
Paths []string
}
type page struct {
Path string
Title string
Description string
Slug string
Body string
}
func main() {
docsDir := flag.String("docs", "docs", "path to the documentation site directory")
outDir := flag.String("out", "dist/wiki", "output directory for wiki pages")
flag.Parse()
if err := run(*docsDir, *outDir); err != nil {
fmt.Fprintf(os.Stderr, "[ERROR] generate wiki: %v\n", err)
os.Exit(1)
}
}
func run(docsDir, outDir string) error {
sections, err := readNavigation(filepath.Join(docsDir, "docs.json"))
if err != nil {
return err
}
pages, err := readPages(docsDir, sections)
if err != nil {
return err
}
links := make(map[string]string, len(pages))
for _, p := range pages {
links[p.Path] = p.Slug
}
for i := range pages {
body, err := render(pages[i].Body, links)
if err != nil {
return fmt.Errorf("%s: %w", pages[i].Path, err)
}
pages[i].Body = body
}
if err := os.MkdirAll(outDir, 0o755); err != nil {
return err
}
if err := removeStalePages(outDir); err != nil {
return err
}
for _, p := range pages {
if err := os.WriteFile(filepath.Join(outDir, p.Slug+".md"), []byte(renderPage(p)), 0o644); err != nil {
return err
}
}
if err := os.WriteFile(filepath.Join(outDir, "_Sidebar.md"), []byte(renderSidebar(sections, pages)), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(outDir, "_Footer.md"), []byte(renderFooter()), 0o644); err != nil {
return err
}
return nil
}
func readNavigation(path string) ([]section, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg siteConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
sections := []section{{}}
for _, raw := range cfg.Navigation.Pages {
var pagePath string
if err := json.Unmarshal(raw, &pagePath); err == nil {
sections[0].Paths = append(sections[0].Paths, pagePath)
continue
}
var group struct {
Group string `json:"group"`
Pages []string `json:"pages"`
}
if err := json.Unmarshal(raw, &group); err != nil {
return nil, fmt.Errorf("parse %s navigation entry: %w", path, err)
}
sections = append(sections, section{
Name: group.Group,
Paths: group.Pages,
})
}
return sections, nil
}
func readPages(docsDir string, sections []section) ([]page, error) {
var paths []string
for _, s := range sections {
paths = append(paths, s.Paths...)
}
if len(paths) == 0 {
return nil, errors.New("docs.json defines no pages")
}
pages := make([]page, 0, len(paths))
slugs := make(map[string]string, len(paths))
for _, p := range paths {
data, err := os.ReadFile(filepath.Join(docsDir, p+".mdx"))
if err != nil {
return nil, fmt.Errorf("read page %q: %w", p, err)
}
title, description, body, err := parseFrontmatter(string(data))
if err != nil {
return nil, fmt.Errorf("page %q: %w", p, err)
}
if title == "" {
return nil, fmt.Errorf("page %q has no title", p)
}
slug := homeSlug
if p != "index" {
slug = slugify(title)
}
if slug == "" {
return nil, fmt.Errorf("page %q title %q produces an empty file name", p, title)
}
if other, ok := slugs[slug]; ok {
return nil, fmt.Errorf("pages %q and %q both map to %s.md", other, p, slug)
}
slugs[slug] = p
pages = append(pages, page{
Path: p,
Title: title,
Description: description,
Slug: slug,
Body: body,
})
}
return pages, nil
}
func parseFrontmatter(src string) (title, description, body string, err error) {
src = strings.ReplaceAll(src, "\r\n", "\n")
if !strings.HasPrefix(src, "---\n") {
return "", "", "", errors.New("missing frontmatter")
}
rest := src[len("---\n"):]
index := strings.Index(rest, "\n---\n")
if index < 0 {
return "", "", "", errors.New("unterminated frontmatter")
}
for _, line := range strings.Split(rest[:index], "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
value = strings.TrimSpace(value)
if unquoted, err := strconv.Unquote(value); err == nil {
value = unquoted
}
switch strings.TrimSpace(key) {
case "title":
title = value
case "description":
description = value
}
}
return title, description, strings.TrimLeft(rest[index+len("\n---\n"):], "\n"), nil
}
var slugSeparators = regexp.MustCompile(`[^A-Za-z0-9]+`)
func slugify(title string) string {
return strings.Trim(slugSeparators.ReplaceAllString(title, "-"), "-")
}
func renderPage(p page) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n", p.Title)
if p.Description != "" {
fmt.Fprintf(&b, "\n*%s*\n", p.Description)
}
b.WriteString("\n")
b.WriteString(strings.TrimRight(p.Body, "\n"))
b.WriteString("\n")
return b.String()
}
func renderSidebar(sections []section, pages []page) string {
byPath := make(map[string]page, len(pages))
for _, p := range pages {
byPath[p.Path] = p
}
var b strings.Builder
b.WriteString("<!-- Generated by tools/genwiki from docs/docs.json. Do not edit this wiki by hand. -->\n")
for _, s := range sections {
if len(s.Paths) == 0 {
continue
}
if s.Name != "" {
fmt.Fprintf(&b, "\n### %s\n\n", s.Name)
}
for _, path := range s.Paths {
p := byPath[path]
fmt.Fprintf(&b, "- [%s](%s)\n", p.Title, p.Slug)
}
}
return b.String()
}
func renderFooter() string {
return fmt.Sprintf("---\n\nGenerated from the [`docs/`](%s) directory. [Read the documentation site](%s).\n", docsRepo, docsSite)
}
func removeStalePages(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil {
return err
}
}
return nil
}
var (
componentOpen = regexp.MustCompile(`^<(Warning|Note|Tip|Important|Caution)>`)
componentClose = regexp.MustCompile(`^</(Warning|Note|Tip|Important|Caution)>$`)
stepOpen = regexp.MustCompile(`^<Step\s+title="([^"]*)">$`)
attribute = regexp.MustCompile(`([A-Za-z][A-Za-z0-9_-]*)(?:="([^"]*)")?`)
internalLink = regexp.MustCompile(`\]\((/[^)\s]*)\)`)
)
func render(body string, links map[string]string) (string, error) {
lines := strings.Split(body, "\n")
out := make([]string, 0, len(lines))
inFence := false
for i := 0; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
if inFence {
out = append(out, line)
if strings.HasPrefix(trimmed, "```") {
inFence = false
}
continue
}
if strings.HasPrefix(trimmed, "```") {
out = append(out, line)
inFence = true
continue
}
switch {
case componentOpen.MatchString(trimmed):
kind := strings.ToUpper(componentOpen.FindStringSubmatch(trimmed)[1])
if end := strings.Index(trimmed, "</"); end >= 0 {
inner := strings.TrimSpace(trimmed[len(componentOpen.FindStringSubmatch(trimmed)[0]):end])
out = appendAlert(out, kind, []string{inner})
continue
}
content, next, err := collectComponent(lines, i+1, "</"+componentOpen.FindStringSubmatch(trimmed)[1]+">")
if err != nil {
return "", err
}
out = appendAlert(out, kind, content)
i = next
case trimmed == "<Steps>" || trimmed == "</Steps>" || trimmed == "</Step>":
continue
case stepOpen.MatchString(trimmed):
title := stepOpen.FindStringSubmatch(trimmed)[1]
content, next, err := collectComponent(lines, i+1, "</Step>")
if err != nil {
return "", err
}
out = append(out, "", "### "+title, "")
out = append(out, dedent(trimBlank(content))...)
out = append(out, "")
i = next
case strings.HasPrefix(trimmed, "<ParamField "):
attrs := parseAttributes(strings.TrimSuffix(strings.TrimSuffix(trimmed, ">"), "/"))
content, next, err := collectComponent(lines, i+1, "</ParamField>")
if err != nil {
return "", err
}
out = append(out, "", renderParamField(attrs), "")
out = append(out, dedent(trimBlank(content))...)
out = append(out, "")
i = next
case strings.HasPrefix(trimmed, "<GitHub.Repo "):
repo := parseAttributes(trimmed)["repo"]
if repo == "" {
return "", fmt.Errorf("GitHub.Repo without repo at line %d", i+1)
}
out = append(out, "", fmt.Sprintf("[github.com/%s](https://github.com/%s)", repo, repo), "")
default:
if componentClose.MatchString(trimmed) {
return "", fmt.Errorf("unexpected %s at line %d", trimmed, i+1)
}
out = append(out, line)
}
}
text := strings.TrimLeft(strings.Join(out, "\n"), "\n")
text = normalizeBlankLines(text)
text = strings.TrimRight(text, "\n") + "\n"
return rewriteLinks(text, links)
}
func normalizeBlankLines(text string) string {
lines := strings.Split(text, "\n")
out := make([]string, 0, len(lines))
inFence := false
blank := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
inFence = !inFence
blank = false
out = append(out, line)
continue
}
if !inFence && trimmed == "" {
if blank {
continue
}
blank = true
out = append(out, "")
continue
}
blank = false
out = append(out, line)
}
return strings.Join(out, "\n")
}
func collectComponent(lines []string, start int, closeTag string) ([]string, int, error) {
for i := start; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == closeTag {
return lines[start:i], i, nil
}
}
return nil, 0, fmt.Errorf("missing %s", closeTag)
}
func appendAlert(out []string, kind string, content []string) []string {
content = dedent(trimBlank(content))
out = append(out, "", "> [!"+kind+"]")
for _, line := range content {
if line == "" {
out = append(out, ">")
continue
}
out = append(out, "> "+line)
}
return append(out, "")
}
func renderParamField(attrs map[string]string) string {
label := attrs["query"]
if label == "" {
label = attrs["path"]
}
parts := make([]string, 0, 3)
if kind := attrs["type"]; kind != "" {
parts = append(parts, kind)
}
if _, ok := attrs["required"]; ok {
parts = append(parts, "required")
}
if value, ok := attrs["default"]; ok {
parts = append(parts, "default: `"+value+"`")
}
line := "**`" + label + "`**"
if len(parts) > 0 {
line += " (" + strings.Join(parts, ", ") + ")"
}
return line
}
func parseAttributes(s string) map[string]string {
attrs := make(map[string]string)
for _, match := range attribute.FindAllStringSubmatch(s, -1) {
attrs[match[1]] = match[2]
}
return attrs
}
func dedent(lines []string) []string {
indent := -1
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
width := len(line) - len(strings.TrimLeft(line, " "))
if indent < 0 || width < indent {
indent = width
}
}
if indent <= 0 {
return lines
}
result := make([]string, len(lines))
for i, line := range lines {
if len(line) >= indent {
result[i] = line[indent:]
continue
}
result[i] = strings.TrimLeft(line, " ")
}
return result
}
func trimBlank(lines []string) []string {
lines = slices.Clone(lines)
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
lines = lines[1:]
}
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
return lines
}
func rewriteLinks(text string, links map[string]string) (string, error) {
lines := strings.Split(text, "\n")
inFence := false
var firstErr error
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if inFence {
if strings.HasPrefix(trimmed, "```") {
inFence = false
}
continue
}
if strings.HasPrefix(trimmed, "```") {
inFence = true
continue
}
if strings.Contains(line, "(") {
lines[i] = rewriteLineLinks(line, links, &firstErr)
}
}
if firstErr != nil {
return "", firstErr
}
return strings.Join(lines, "\n"), nil
}
func rewriteLineLinks(line string, links map[string]string, firstErr *error) string {
return internalLink.ReplaceAllStringFunc(line, func(match string) string {
target := strings.TrimSuffix(strings.TrimPrefix(match, "]("), ")")
path, anchor, hasAnchor := strings.Cut(target, "#")
key := strings.TrimPrefix(path, "/")
if key == "" {
key = "index"
}
slug, ok := links[key]
if !ok {
if *firstErr == nil {
*firstErr = fmt.Errorf("link target %q has no page in docs.json", target)
}
return match
}
if hasAnchor {
return "](" + slug + "#" + anchor + ")"
}
return "](" + slug + ")"
})
}
package bmclapi
import (
"context"
"fmt"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/providers/forge"
"github.com/mclucy/lucy/upstream/providers/mojang"
"github.com/mclucy/lucy/upstream/providers/neoforge"
)
const (
minecraftBaseURL = "https://bmclapi2.bangbang93.com/version/"
mavenBaseURL = "https://bmclapi2.bangbang93.com/maven/"
forgeMavenBaseURL = "https://maven.minecraftforge.net/"
neoForgeMavenBaseURL = "https://maven.neoforged.net/releases/"
)
// Provider maps official server artifacts onto the BMCLAPI mirror. It resolves
// versions through the official providers it was constructed with, so
// floating selectors and published digests keep their canonical meaning.
type Provider struct {
mojang *mojang.Provider
forge *forge.Provider
neoforge *neoforge.Provider
}
// New constructs a BMCLAPI mirror provider over the official providers whose
// artifacts it mirrors.
func New(
mojangProvider *mojang.Provider,
forgeProvider *forge.Provider,
neoForgeProvider *neoforge.Provider,
) *Provider {
return &Provider{
mojang: mojangProvider,
forge: forgeProvider,
neoforge: neoForgeProvider,
}
}
func (p *Provider) Provider() catalog.Provider {
return catalog.ProviderBMCLAPI
}
func (p *Provider) ResolveVersionSelector(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (catalog.VersionedRef, error) {
switch id.Ecosystem {
case catalog.EcosystemMinecraft:
return p.mojang.ResolveVersionSelector(ctx, local, id)
case catalog.EcosystemForge:
return p.forge.ResolveVersionSelector(ctx, local, id)
case catalog.EcosystemNeoForge:
return p.neoforge.ResolveVersionSelector(ctx, local, id)
default:
return id, unsupportedEcosystem(id.Ecosystem)
}
}
func (p *Provider) Fetch(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (upstream.ResolvedPackage, error) {
switch id.Ecosystem {
case catalog.EcosystemMinecraft:
resolved, err := p.mojang.Fetch(ctx, local, id)
if err != nil {
return upstream.ResolvedPackage{}, fmt.Errorf("bmclapi: fetch minecraft metadata: %w", err)
}
resolved.FileURL = minecraftBaseURL + id.Version.String() + "/server"
return resolved, nil
case catalog.EcosystemForge:
resolved, err := p.forge.Fetch(ctx, local, id)
if err != nil {
return upstream.ResolvedPackage{}, fmt.Errorf("bmclapi: fetch forge artifact: %w", err)
}
resolved.FileURL, err = mirrorMavenURL(resolved.FileURL, forgeMavenBaseURL)
if err != nil {
return upstream.ResolvedPackage{}, fmt.Errorf("bmclapi: map forge artifact URL: %w", err)
}
return resolved, nil
case catalog.EcosystemNeoForge:
resolved, err := p.neoforge.Fetch(ctx, local, id)
if err != nil {
return upstream.ResolvedPackage{}, fmt.Errorf("bmclapi: fetch neoforge artifact: %w", err)
}
resolved.FileURL, err = mirrorMavenURL(resolved.FileURL, neoForgeMavenBaseURL)
if err != nil {
return upstream.ResolvedPackage{}, fmt.Errorf("bmclapi: map neoforge artifact URL: %w", err)
}
return resolved, nil
default:
return upstream.ResolvedPackage{}, unsupportedEcosystem(id.Ecosystem)
}
}
func mirrorMavenURL(fileURL, officialPrefix string) (string, error) {
path, ok := strings.CutPrefix(fileURL, officialPrefix)
if !ok {
return "", fmt.Errorf("unrecognized official Maven URL %q", fileURL)
}
return mavenBaseURL + path, nil
}
func unsupportedEcosystem(ecosystem catalog.Ecosystem) error {
return fmt.Errorf("bmclapi: unsupported ecosystem %s", ecosystem)
}
// Package curseforge provides functions to interact with CurseForge API.
//
// CurseForge identifies mods by numeric modId, not by slug. Slug resolution
// is done via the search endpoint with the slug query parameter.
//
// All API requests require an x-api-key header. The key is supplied once to
// New and never read from ambient global state.
package curseforge
import (
"context"
"fmt"
"strings"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
)
// Provider is one configured CurseForge API client. Every request it performs
// shares the network cache, logger, and API key it was constructed with.
type Provider struct {
network *cache.Client
logger *log.Logger
apiKey string
}
// New constructs a CurseForge provider over network, logger, and apiKey.
// Construction performs no network requests and decodes no key.
func New(
network *cache.Client,
logger *log.Logger,
apiKey string,
) *Provider {
return &Provider{
network: network,
logger: logger,
apiKey: strings.TrimSpace(apiKey),
}
}
func (p *Provider) Provider() catalog.Provider {
return catalog.ProviderCurseForge
}
func (p *Provider) Search(
ctx context.Context,
q upstream.Query,
) (upstream.SearchResponse, error) {
// CurseForge exposes no client/server search facet; IncludeClient cannot
// be applied upstream and is left to post-filtering.
u := searchUrl(catalog.Name(q.Keyword), q.FilterEcosystem)
p.logger.Debug("searching via curseforge api: " + u)
resp := &searchResponse{}
if err := p.get(ctx, u, resp); err != nil {
return upstream.SearchResponse{}, err
}
return resp.ToSearchResults(p.Provider()), nil
}
func (p *Provider) Fetch(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (upstream.ResolvedPackage, error) {
mod, err := p.resolveSlug(ctx, id.Name)
if err != nil {
return upstream.ResolvedPackage{}, err
}
file, err := p.getFileByDisplayName(
ctx,
mod.ID,
string(id.Version),
local,
id.Ecosystem,
)
if err != nil {
return upstream.ResolvedPackage{}, err
}
resolved := file.ToPackageRemote()
resolved.ID.Ref = id.Ref
resolved.ID.Version = id.Version
return resolved, nil
}
func (p *Provider) Info(
ctx context.Context,
ref catalog.Ref,
) (catalog.Metadata, error) {
mod, err := p.resolveSlug(ctx, ref.Name)
if err != nil {
return catalog.Metadata{}, err
}
description, err := p.getModDescription(ctx, mod.ID)
if err != nil {
return catalog.Metadata{}, err
}
info := rawProjectInformation{
mod: mod, description: description,
}.ToProjectInformation()
return info, nil
}
func (p *Provider) Dependencies(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (*upstream.DependencySet, error) {
mod, err := p.resolveSlug(ctx, id.Name)
if err != nil {
return nil, err
}
file, err := p.getFileByDisplayName(
ctx,
mod.ID,
string(id.Version),
local,
id.Ecosystem,
)
if err != nil {
return nil, err
}
return new((&curseforgeDependencies{file: file}).ToDependencySet()), nil
}
// curseforgeDependencies wraps a fileResponse for dependency
// normalization.
type curseforgeDependencies struct {
file *fileResponse
}
func (c *curseforgeDependencies) ToDependencySet() upstream.DependencySet {
result := upstream.DependencySet{
FromArtifact: false,
}
for _, dep := range c.file.Dependencies {
// relationType mapping:
// 1 = EmbeddedLibrary (skip - embedded in the mod itself)
// 2 = OptionalDependency -> Mandatory: false
// 3 = RequiredDependency -> Mandatory: true
// 4 = Tool (skip - not a runtime dependency)
// 5 = Incompatible (skip - breaks compatibility)
// 6 = Include (skip - bundled with the mod)
switch dep.RelationType {
case 2:
result.Dependencies = append(
result.Dependencies, catalog.Dependency{
ID: catalog.VersionedRef{
Ref: catalog.Ref{
Name: catalog.Name(
fmt.Sprintf(
"%d",
dep.ModId,
),
),
},
},
Mandatory: false,
},
)
case 3:
result.Dependencies = append(
result.Dependencies, catalog.Dependency{
ID: catalog.VersionedRef{
Ref: catalog.Ref{
Name: catalog.Name(
fmt.Sprintf(
"%d",
dep.ModId,
),
),
},
},
Mandatory: true,
},
)
default:
continue
}
}
return result
}
// ResolveVersionSelector resolves abstract version specifiers (any, stable,
// beta) to a concrete version by querying the CurseForge API.
func (p *Provider) ResolveVersionSelector(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (parsed catalog.VersionedRef, err error) {
if id.Ecosystem.IsUnspecified() {
id.Ecosystem = catalog.EcosystemUnspecified
}
parsed.Ecosystem = id.Ecosystem
parsed.Name = id.Name
mod, err := p.resolveSlug(ctx, id.Name)
if err != nil {
return id, err
}
var file *fileResponse
switch id.Version {
case version.Stable, version.Beta, version.Any:
file, err = p.latestFile(ctx, mod.ID, local, id.Ecosystem, id.Version)
default:
return id, nil
}
if err != nil {
return id, err
}
parsed.Version = version.Version(file.FileName)
return parsed, nil
}
package curseforge
import (
"context"
"strings"
"github.com/mclucy/lucy/upstream"
)
// Builds lists every file published by a CurseForge project. project accepts
// either a numeric mod ID or a slug. The list is unfiltered: no game version,
// loader, channel, availability flag, or download URL state is excluded.
func (p *Provider) Builds(
ctx context.Context,
project string,
) ([]upstream.Build, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
modID, err := p.resolveProjectID(ctx, project)
if err != nil {
return nil, err
}
files, err := p.listAllFiles(ctx, modID, "", 0)
if err != nil {
return nil, err
}
builds := make([]upstream.Build, len(files))
for i := range files {
gameVersions, loaders := splitGameVersions(files[i].GameVersions)
builds[i] = upstream.Build{
Version: files[i].DisplayName,
GameVersions: gameVersions,
Loaders: loaders,
Channel: string(curseForgeChannel(files[i].ChannelCode)),
Published: parseCurseforgeDate(files[i].FileDate),
}
}
return builds, nil
}
// curseForgeLoaderMarkers holds the loader names CurseForge mixes into a
// file's gameVersions list. Keys are the lowercase loader identifiers used by
// the other providers.
var curseForgeLoaderMarkers = map[string]struct{}{
"forge": {},
"cauldron": {},
"liteloader": {},
"fabric": {},
"quilt": {},
"neoforge": {},
"modloader": {},
"rift": {},
"bukkit": {},
"spigot": {},
"paper": {},
"purpur": {},
"folia": {},
"sponge": {},
"velocity": {},
"bungeecord": {},
"waterfall": {},
"travertine": {},
}
// splitGameVersions separates CurseForge's mixed gameVersions list into game
// versions and loader identifiers. Game versions start with a digit; loader
// markers are matched case-insensitively against the known loader names.
// Unrecognized markers are left out rather than being misreported as either.
func splitGameVersions(entries []string) (
gameVersions []string,
loaders []string,
) {
for _, entry := range entries {
trimmed := strings.TrimSpace(entry)
if trimmed == "" {
continue
}
lower := strings.ToLower(trimmed)
if _, ok := curseForgeLoaderMarkers[lower]; ok {
loaders = append(loaders, lower)
continue
}
if isGameVersion(trimmed) {
gameVersions = append(gameVersions, trimmed)
}
}
return gameVersions, loaders
}
func isGameVersion(s string) bool {
if s == "" {
return false
}
c := s[0]
return c >= '0' && c <= '9'
}
package curseforge
import (
"context"
"fmt"
"strconv"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
)
func (p *Provider) Candidates(ctx context.Context, project, minecraft string, loader catalog.Ecosystem) ([]upstream.Candidate, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
id, err := p.resolveProjectID(ctx, project)
if err != nil {
return nil, err
}
// Pagination is required: exact older files are not necessarily on page one.
files, err := p.listAllFiles(ctx, id, minecraft, modLoaderType(loader))
if err != nil {
return nil, err
}
var out []upstream.Candidate
for _, f := range files {
if f.DownloadUrl == nil {
continue
}
hashes := map[string]string{}
for _, h := range f.Hashes {
if h.Algo == 1 {
hashes["sha1"] = h.Digest
}
}
c := upstream.Candidate{
Provider: catalog.ProviderCurseForge,
ProjectID: strconv.FormatInt(int64(id), 10),
ReleaseID: strconv.Itoa(int(f.ID)),
FileID: strconv.Itoa(int(f.ID)),
Version: version.Version(f.DisplayName),
Filename: f.FileName,
URL: *f.DownloadUrl,
Hashes: hashes,
Channel: curseForgeChannel(f.ChannelCode),
PublishedAt: parseCurseforgeDate(f.FileDate),
PrimaryFile: true,
Dependencies: []upstream.ProjectDependency{},
}
for _, d := range f.Dependencies {
var kind catalog.DependencyKind
switch d.RelationType {
case 3:
kind = catalog.DependencyKindRequired
case 2:
kind = catalog.DependencyKindOptional
case 5:
kind = catalog.DependencyKindIncompatible
}
if kind != catalog.DependencyKindUnknown {
c.Dependencies = append(c.Dependencies, upstream.ProjectDependency{
Provider: catalog.ProviderCurseForge,
ProjectID: strconv.Itoa(int(d.ModId)),
Kind: kind,
})
}
}
out = append(out, c)
}
if len(out) == 0 {
return nil, fmt.Errorf("curseforge project %s has no downloadable %s release for Minecraft %s", project, loader, minecraft)
}
return out, nil
}
package curseforge
import (
"time"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/upstream"
)
// --- API response wrappers ---
// searchResponse wraps the CurseForge /v1/mods/search response.
type searchResponse struct {
Data []modResponse `json:"data"`
Pagination pagination `json:"pagination"`
}
func (s *searchResponse) ToSearchResults(source catalog.Provider) upstream.SearchResponse {
res := upstream.SearchResponse{
Provider: source,
Items: make([]upstream.SearchResult, 0, len(s.Data)),
}
for _, mod := range s.Data {
res.Items = append(
res.Items, upstream.SearchResult{
ProjectName: input.ToProjectName(mod.Slug).String(),
Provider: source,
Title: mod.Name,
Description: mod.Summary,
Downloads: mod.DownloadCount,
LastUpdated: parseCurseforgeDate(mod.DateModified),
},
)
}
return res
}
// parseCurseforgeDate parses CurseForge's RFC3339 timestamp.
// Returns zero time on failure or empty input.
func parseCurseforgeDate(s string) time.Time {
if s == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}
}
return t
}
// modDataResponse wraps /v1/mods/{modId} response.
type modDataResponse struct {
Data modResponse `json:"data"`
}
type stringDataResponse struct {
Data string `json:"data"`
}
// modResponse is the CurseForge Mod schema.
type modResponse struct {
ID int32 `json:"id"`
GameId int32 `json:"gameId"`
Name string `json:"name"`
Slug string `json:"slug"`
Links modLinks `json:"links"`
Summary string `json:"summary"`
Status int32 `json:"status"`
DownloadCount int64 `json:"downloadCount"`
IsFeatured bool `json:"isFeatured"`
CategoryID int32 `json:"primaryCategoryId"`
ClassId *int32 `json:"classId"`
Authors []modAuthor `json:"authors"`
Logo *modAsset `json:"logo"`
MainFileId int32 `json:"mainFileId"`
LatestFiles []fileResponse `json:"latestFiles"`
LatestFilesIndexes []fileIndex `json:"latestFilesIndexes"`
DateCreated string `json:"dateCreated"`
DateModified string `json:"dateModified"`
DateReleased string `json:"dateReleased"`
AllowModDistribution *bool `json:"allowModDistribution"`
GamePopularityRank int32 `json:"gamePopularityRank"`
IsAvailable bool `json:"isAvailable"`
ThumbsUpCount int32 `json:"thumbsUpCount"`
}
type rawProjectInformation struct {
mod *modResponse
description string
}
func (m *modResponse) ToProjectInformation() catalog.Metadata {
return rawProjectInformation{mod: m}.ToProjectInformation()
}
func (r rawProjectInformation) ToProjectInformation() catalog.Metadata {
m := r.mod
info := catalog.Metadata{
Title: m.Name,
Brief: m.Summary,
Description: r.description,
DescriptionIsMarkdown: upstream.LooksLikeMarkdown(r.description),
URLs: make([]catalog.URL, 0),
Authors: make([]catalog.Person, 0, len(m.Authors)),
}
if m.Links.WebsiteUrl != "" {
info.URLs = append(
info.URLs, catalog.URL{
Name: "Website",
Type: catalog.URLHome,
URL: m.Links.WebsiteUrl,
},
)
}
if m.Links.WikiUrl != "" {
info.URLs = append(
info.URLs, catalog.URL{
Name: "Wiki",
Type: catalog.URLWiki,
URL: m.Links.WikiUrl,
},
)
}
if m.Links.IssuesUrl != "" {
info.URLs = append(
info.URLs, catalog.URL{
Name: "Issues",
Type: catalog.URLIssues,
URL: m.Links.IssuesUrl,
},
)
}
if m.Links.SourceUrl != "" {
info.URLs = append(
info.URLs, catalog.URL{
Name: "Source",
Type: catalog.URLSource,
URL: m.Links.SourceUrl,
},
)
}
for _, author := range m.Authors {
info.Authors = append(
info.Authors, catalog.Person{
Name: author.Name,
URL: author.URL,
},
)
}
return info
}
type modLinks struct {
WebsiteUrl string `json:"websiteUrl"`
WikiUrl string `json:"wikiUrl"`
IssuesUrl string `json:"issuesUrl"`
SourceUrl string `json:"sourceUrl"`
}
type modAuthor struct {
ID int32 `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
}
type modAsset struct {
ID int32 `json:"id"`
ModId int32 `json:"modId"`
Title string `json:"title"`
Description string `json:"description"`
ThumbnailUrl string `json:"thumbnailUrl"`
URL string `json:"url"`
}
// --- File-level structs ---
// filesResponse wraps /v1/mods/{modId}/files response.
type filesResponse struct {
Data []fileResponse `json:"data"`
Pagination pagination `json:"pagination"`
}
// fileResponse is the CurseForge File schema.
type fileResponse struct {
ID int32 `json:"id"`
GameId int32 `json:"gameId"`
ModId int32 `json:"modId"`
IsAvailable bool `json:"isAvailable"`
DisplayName string `json:"displayName"`
FileName string `json:"fileName"`
ChannelCode int32 `json:"releaseType"` // 1=Release, 2=Beta, 3=Alpha
FileStatus int32 `json:"fileStatus"`
Hashes []fileHash `json:"hashes"`
FileDate string `json:"fileDate"`
FileLength int64 `json:"fileLength"`
DownloadCount int64 `json:"downloadCount"`
// Docs: https://docs.curseforge.com/rest-api/#get-mod-files
DownloadUrl *string `json:"downloadUrl"` // CAN BE NULL
GameVersions []string `json:"gameVersions"`
Dependencies []fileDependency `json:"dependencies"`
IsServerPack *bool `json:"isServerPack"`
ServerPackFileId *int32 `json:"serverPackFileId"`
}
func (f *fileResponse) ToPackageRemote() upstream.ResolvedPackage {
remote := upstream.ResolvedPackage{
ID: catalog.VersionedRef{Ref: catalog.Ref{Provider: catalog.ProviderCurseForge}},
Filename: f.FileName,
}
if f.DownloadUrl != nil {
remote.FileURL = *f.DownloadUrl
}
// Prefer SHA1 over MD5 (algo 1=sha1, 2=md5)
for _, h := range f.Hashes {
if h.Algo == 1 {
remote.Hash = h.Digest
remote.HashAlgorithm = "sha1"
break
}
}
if remote.Hash == "" {
for _, h := range f.Hashes {
if h.Algo == 2 {
remote.Hash = h.Digest
remote.HashAlgorithm = "md5"
break
}
}
}
return remote
}
type fileHash struct {
Digest string `json:"value"`
Algo int32 `json:"algo"` // 1=Sha1, 2=Md5
}
type fileDependency struct {
ModId int32 `json:"modId"`
RelationType int32 `json:"relationType"` // 3=Required, 2=Optional, etc.
}
type fileIndex struct {
GameVersion string `json:"gameVersion"`
FileId int32 `json:"fileId"`
Filename string `json:"filename"`
ChannelCode int32 `json:"releaseType"`
GameVersionTypeId *int32 `json:"gameVersionTypeId"`
ModLoader *int32 `json:"modLoader"` // 0=Any, 1=Forge, 4=Fabric, 6=NeoForge
}
type pagination struct {
Index int32 `json:"index"`
PageSize int32 `json:"pageSize"`
ResultCount int32 `json:"resultCount"`
TotalCount int32 `json:"totalCount"`
}
package curseforge
import (
"errors"
"fmt"
)
type ApiResponseError struct {
StatusCode int
}
func (e ApiResponseError) Error() string {
return fmt.Sprintf("curseforge: API returned status %d", e.StatusCode)
}
var (
ErrProjectNotFound = errors.New("curseforge: project not found")
ErrAmbiguousSlug = errors.New("curseforge: ambiguous slug, multiple projects matched")
ErrDownloadNotAllowed = errors.New("curseforge: download not allowed by mod author")
ErrNoCompatibleFile = errors.New("curseforge: no compatible file found")
ErrNoApiKey = errors.New("curseforge: API key not configured")
ErrInvalidApiKey = errors.New("curseforge: API key rejected")
ErrApiResponse = func(statusCode int) error {
return ApiResponseError{StatusCode: statusCode}
}
)
package curseforge
import (
"bytes"
"context"
"encoding/json/v2"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/internal/fn"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
)
// fingerprintRequest is the body for POST /v1/fingerprints/432.
type fingerprintRequest struct {
Fingerprints []uint32 `json:"fingerprints"`
}
// fingerprintResponse wraps the /v1/fingerprints/432 response.
// Verified against https://docs.curseforge.com/rest-api/#get-fingerprints-matches
type fingerprintResponse struct {
Data struct {
ExactMatches []struct {
ID uint32 `json:"id"`
File struct {
ModId int32 `json:"modId"`
} `json:"file"`
} `json:"exactMatches"`
} `json:"data"`
}
func (p *Provider) PackageByHash(
ctx context.Context,
hashable upstream.ArtifactFingerprint,
) (
ref catalog.VersionedRef,
hash string,
ok bool,
err error,
) {
fingerprint, err := hashable.MurmurHash()
if err != nil {
return ref, hash, false, err
}
hash = strconv.FormatUint(uint64(fingerprint), 10)
match, err := p.fingerprintMatch(ctx, fingerprint)
if err != nil {
if errors.Is(err, ErrProjectNotFound) {
return ref, hash, false, nil
}
return ref, hash, false, err
}
mod, err := p.getModById(ctx, match.ModId)
if err != nil {
return ref, hash, false, err
}
file, err := p.getModFileById(ctx, match.ModId, match.FileId)
if err != nil {
return ref, hash, false, err
}
ref = catalog.VersionedRef{
Name: catalog.Name(mod.Slug),
Provider: p.Provider(),
Ecosystem: platformFromCurseForgeFile(mod, file),
Version: version.Version(file.DisplayName),
}
return ref, hash, true, nil
}
type fingerprintMatchResult struct {
ModId int32
FileId int32
}
func (p *Provider) fingerprintMatch(
ctx context.Context,
fp uint32,
) (fingerprintMatchResult, error) {
var zero fingerprintMatchResult
if p.apiKey == "" {
return zero, ErrNoApiKey
}
body, _ := json.Marshal(fingerprintRequest{Fingerprints: []uint32{fp}})
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
baseUrl+"/v1/fingerprints/432",
bytes.NewReader(body),
)
if err != nil {
return zero, err
}
req.Header.Set("x-api-key", p.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
p.logger.Debug("curseforge fingerprint lookup")
resp, err := p.network.Do(req)
if err != nil {
return zero, err
}
defer fn.CloseReader(resp.Body, p.logger.Warn)
if resp.StatusCode != http.StatusOK {
return zero, fmt.Errorf(
"curseforge: fingerprint lookup returned status %d",
resp.StatusCode,
)
}
const maxFingerprintResponseBytes = 4 * 1024 * 1024
raw, err := fsutil.CopyBytes(resp.Body, maxFingerprintResponseBytes)
if err != nil {
return zero, err
}
var result fingerprintResponse
if err := json.Unmarshal(raw, &result); err != nil {
return zero, ErrProjectNotFound
}
if len(result.Data.ExactMatches) == 0 {
return zero, ErrProjectNotFound
}
m := result.Data.ExactMatches[0]
return fingerprintMatchResult{
ModId: m.File.ModId,
FileId: int32(m.ID),
}, nil
}
func platformFromCurseForgeFile(
mod *modResponse,
file *fileResponse,
) catalog.Ecosystem {
for _, idx := range mod.LatestFilesIndexes {
if idx.FileId == file.ID && idx.ModLoader != nil {
return platformFromCurseForgeModLoader(*idx.ModLoader)
}
}
return catalog.EcosystemUnspecified
}
func platformFromCurseForgeModLoader(loader int32) catalog.Ecosystem {
switch loader {
case 1:
return catalog.EcosystemForge
case 4:
return catalog.EcosystemFabric
case 6:
return catalog.EcosystemNeoForge
default:
return catalog.EcosystemUnspecified
}
}
package curseforge
import (
"context"
"encoding/json/v2"
"fmt"
"net/http"
"github.com/mclucy/lucy/cache"
)
// Docs: https://docs.curseforge.com/rest-api/
const baseUrl = "https://api.curseforge.com"
// get performs an authenticated GET request to the CurseForge API and
// unmarshals the JSON response into dest.
func (p *Provider) get(ctx context.Context, url string, dest any) error {
if p.apiKey == "" {
return ErrNoApiKey
}
p.logger.Debug("curseforge api: GET " + url)
headers := http.Header{}
headers.Set("x-api-key", p.apiKey)
headers.Set("Accept", "application/json")
res, err := p.network.CachedGetRequest(
ctx,
url,
cache.BytesRequestOptions{
Kind: cache.KindMetadata,
Headers: headers,
},
)
if err != nil {
return fmt.Errorf("curseforge: request failed: %w", err)
}
if res.StatusCode != http.StatusOK {
return ErrApiResponse(res.StatusCode)
}
if err := json.Unmarshal(res.Data, dest); err != nil {
return fmt.Errorf("curseforge: failed to parse response: %w", err)
}
return nil
}
package curseforge
import (
"github.com/mclucy/lucy/upstream"
// selectLatestFile applies issue 60 selector semantics. stable accepts
// releases only; beta accepts any channel; any prefers a release and falls
// back to a prerelease.
versionpkg "github.com/mclucy/lucy/version"
)
func selectLatestFile(
files []fileResponse,
selector versionpkg.Version,
) *fileResponse {
switch selector {
case versionpkg.Stable:
return selectLatestReleaseFile(files)
case versionpkg.Beta:
return selectLatestAnyFile(files)
default:
if latest := selectLatestReleaseFile(files); latest != nil {
return latest
}
return selectLatestAnyFile(files)
}
}
func selectLatestAnyFile(files []fileResponse) *fileResponse {
var latest *fileResponse
for i := range files {
candidate := &files[i]
if !candidate.IsAvailable {
continue
}
if latest == nil || candidate.FileDate > latest.FileDate {
latest = candidate
}
}
return latest
}
func selectLatestReleaseFile(files []fileResponse) *fileResponse {
var latest *fileResponse
for i := range files {
candidate := &files[i]
if !candidate.IsAvailable || candidate.ChannelCode != 1 {
continue
}
if latest == nil || candidate.FileDate > latest.FileDate {
latest = candidate
}
}
return latest
}
// curseForgeChannel maps the CurseForge releaseType enum (1=Release,
// 2=Beta, 3=Alpha) to a typed release channel.
func curseForgeChannel(releaseType int32) upstream.ReleaseChannel {
switch releaseType {
case 1:
return upstream.ReleaseChannelRelease
case 2:
return upstream.ReleaseChannelBeta
case 3:
return upstream.ReleaseChannelAlpha
default:
return upstream.ReleaseChannelUnknown
}
}
func selectFileByVersion(files []fileResponse, version string) *fileResponse {
for i := range files {
candidate := &files[i]
if !candidate.IsAvailable {
continue
}
if candidate.DisplayName == version || candidate.FileName == version {
return candidate
}
}
return nil
}
package curseforge
import (
"context"
"strconv"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
)
// resolveProjectID resolves a project identity to a CurseForge mod ID. A
// numeric string is treated as an ID; anything else is resolved as a slug.
func (p *Provider) resolveProjectID(
ctx context.Context,
project string,
) (int32, error) {
if id, err := strconv.ParseInt(project, 10, 32); err == nil {
return int32(id), nil
}
mod, err := p.resolveSlug(ctx, catalog.Name(project))
if err != nil {
return 0, err
}
return mod.ID, nil
}
// resolveSlug resolves a project slug to its mod data. CurseForge has no
// get-by-slug endpoint, so it searches by slug and requires an exact match.
// Docs: https://docs.curseforge.com/rest-api/#search-mods
func (p *Provider) resolveSlug(
ctx context.Context,
slug catalog.Name,
) (*modResponse, error) {
u := slugSearchUrl(slug)
var resp searchResponse
if err := p.get(ctx, u, &resp); err != nil {
return nil, err
}
if len(resp.Data) == 0 {
return nil, ErrProjectNotFound
}
if len(resp.Data) == 1 {
return &resp.Data[0], nil
}
for i := range resp.Data {
if input.ToProjectName(resp.Data[i].Slug) == slug {
return &resp.Data[i], nil
}
}
return nil, ErrAmbiguousSlug
}
// Docs: https://docs.curseforge.com/rest-api/#get-mod
func (p *Provider) getModById(
ctx context.Context,
modId int32,
) (*modResponse, error) {
u := modUrl(modId)
var resp modDataResponse
if err := p.get(ctx, u, &resp); err != nil {
return nil, err
}
return &resp.Data, nil
}
func (p *Provider) getModDescription(
ctx context.Context,
modId int32,
) (string, error) {
u := modDescriptionUrl(modId, true)
var resp stringDataResponse
if err := p.get(ctx, u, &resp); err != nil {
return "", err
}
return resp.Data, nil
}
package curseforge
import (
"fmt"
"net/url"
"github.com/mclucy/lucy/catalog"
)
const (
minecraftGameId = 432
modsClassId = 6
)
// modLoaderType maps lucy Platform to CurseForge ModLoaderType enum.
// Docs: https://docs.curseforge.com/rest-api/#search-mods
func modLoaderType(p catalog.Ecosystem) int {
switch p {
case catalog.EcosystemForge:
return 1
case catalog.EcosystemFabric:
return 4
case catalog.EcosystemNeoForge:
return 6
default:
return 0 // Any
}
}
// Docs: https://docs.curseforge.com/rest-api/#search-mods
func searchUrl(
query catalog.Name,
ecosystem catalog.Ecosystem,
) string {
params := url.Values{}
params.Set("gameId", fmt.Sprintf("%d", minecraftGameId))
params.Set("classId", fmt.Sprintf("%d", modsClassId))
params.Set("searchFilter", string(query))
// sortField=2 maps to Popularity in CurseForge ModsSearchSortField enum;
// sortOrder=desc is its required direction. See Docs link above.
params.Set("sortField", "2")
params.Set("sortOrder", "desc")
params.Set("pageSize", "50")
if loader := modLoaderType(ecosystem); loader != 0 {
params.Set("modLoaderType", fmt.Sprintf("%d", loader))
}
return baseUrl + "/v1/mods/search?" + params.Encode()
}
// Docs: https://docs.curseforge.com/rest-api/#search-mods
func slugSearchUrl(slug catalog.Name) string {
params := url.Values{}
params.Set("gameId", fmt.Sprintf("%d", minecraftGameId))
params.Set("classId", fmt.Sprintf("%d", modsClassId))
params.Set("slug", string(slug))
params.Set("pageSize", "50")
return baseUrl + "/v1/mods/search?" + params.Encode()
}
// Docs: https://docs.curseforge.com/rest-api/#get-mod
func modUrl(modId int32) string {
return fmt.Sprintf("%s/v1/mods/%d", baseUrl, modId)
}
// Docs: https://docs.curseforge.com/rest-api/#get-mod-description
func modDescriptionUrl(modId int32, stripped bool) string {
params := url.Values{}
if stripped {
params.Set("stripped", "true")
}
u := fmt.Sprintf("%s/v1/mods/%d/description", baseUrl, modId)
if len(params) == 0 {
return u
}
return u + "?" + params.Encode()
}
// Docs: https://docs.curseforge.com/rest-api/#get-mod-files
func modFilesUrl(modId int32, gameVersion string, loaderType int) string {
params := url.Values{}
params.Set("pageSize", "50")
if gameVersion != "" {
params.Set("gameVersion", gameVersion)
}
if loaderType != 0 {
params.Set("modLoaderType", fmt.Sprintf("%d", loaderType))
}
return fmt.Sprintf(
"%s/v1/mods/%d/files?%s",
baseUrl,
modId,
params.Encode(),
)
}
// Docs: https://docs.curseforge.com/rest-api/#get-mod-file
func modFileURL(modId, fileId int32) string {
return fmt.Sprintf("%s/v1/mods/%d/files/%d", baseUrl, modId, fileId)
}
package curseforge
import (
"context"
"strconv"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
// Docs: https://docs.curseforge.com/rest-api/#get-mod-files
func (p *Provider) listFiles(
ctx context.Context,
modId int32,
gameVersion string,
loaderType int,
) ([]fileResponse, error) {
u := modFilesUrl(modId, gameVersion, loaderType)
var resp filesResponse
if err := p.get(ctx, u, &resp); err != nil {
return nil, err
}
return resp.Data, nil
}
// listAllFiles pages through every file of a mod for the given compatibility
// filter. An empty gameVersion and zero loaderType list all files unfiltered.
func (p *Provider) listAllFiles(
ctx context.Context,
modID int32,
gameVersion string,
loaderType int,
) ([]fileResponse, error) {
// Must match the pageSize set by modFilesUrl.
const pageSize = 50
var files []fileResponse
for offset := 0; ; offset += pageSize {
u := modFilesUrl(modID, gameVersion, loaderType) +
"&index=" + strconv.Itoa(offset)
var page filesResponse
if err := p.get(ctx, u, &page); err != nil {
return nil, err
}
files = append(files, page.Data...)
if len(page.Data) < pageSize {
break
}
if page.Pagination.TotalCount > 0 &&
int32(len(files)) >= page.Pagination.TotalCount {
break
}
}
return files, nil
}
// latestFile finds the latest file compatible with local runtime facts and
// the requested selector. stable accepts releases only; beta accepts any
// channel; any prefers a release and falls back to a prerelease.
func (p *Provider) latestFile(
ctx context.Context,
modId int32,
local upstream.LocalContext,
platform catalog.Ecosystem,
selector versionpkg.Version,
) (*fileResponse, error) {
files, err := p.listFiles(
ctx,
modId,
curseForgeGameVersion(local),
modLoaderType(platform),
)
if err != nil {
return nil, err
}
latest := selectLatestFile(files, selector)
if latest == nil {
return nil, ErrNoCompatibleFile
}
if latest.DownloadUrl == nil {
return nil, ErrDownloadNotAllowed
}
return latest, nil
}
func (p *Provider) getFileByDisplayName(
ctx context.Context,
modId int32,
version string,
local upstream.LocalContext,
platform catalog.Ecosystem,
) (*fileResponse, error) {
files, err := p.listFiles(
ctx,
modId,
curseForgeGameVersion(local),
modLoaderType(platform),
)
if err != nil {
return nil, err
}
selected := selectFileByVersion(files, version)
if selected == nil {
return nil, ErrNoCompatibleFile
}
if selected.DownloadUrl == nil {
return nil, ErrDownloadNotAllowed
}
return selected, nil
}
func curseForgeGameVersion(local upstream.LocalContext) string {
if !local.HasMinecraftVersion() {
return ""
}
return local.MinecraftVersion.String()
}
type modFileDataResponse struct {
Data fileResponse `json:"data"`
}
func (p *Provider) getModFileById(
ctx context.Context,
modId, fileId int32,
) (*fileResponse, error) {
u := modFileURL(modId, fileId)
var resp modFileDataResponse
if err := p.get(ctx, u, &resp); err != nil {
return nil, err
}
return &resp.Data, nil
}
package fabric
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"time"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
)
const metaBaseURL = "https://meta.fabricmc.net"
// Provider resolves Fabric loader, installer, and game metadata over the shared
// network cache.
type Provider struct {
network *cache.Client
}
// New constructs a Fabric installer provider. network supplies the cached HTTP
// transport; construction performs no requests.
func New(network *cache.Client) *Provider {
return &Provider{network: network}
}
func (p *Provider) Provider() catalog.Provider {
return catalog.ProviderFabric
}
type installerVersion struct {
Version string `json:"version"`
Stable bool `json:"stable"`
}
type loaderVersionEntry struct {
Version string `json:"version"`
Stable bool `json:"stable"`
}
type gameVersionEntry struct {
Version string `json:"version"`
Stable bool `json:"stable"`
}
func (p *Provider) ResolveVersionSelector(
ctx context.Context,
_ upstream.LocalContext,
id catalog.VersionedRef,
) (catalog.VersionedRef, error) {
loaderVer, err := p.loaderVersion(ctx, id.Version)
if err != nil {
return id, err
}
return catalog.VersionedRef{
Ecosystem: catalog.EcosystemFabric,
Name: "fabric",
Version: version.Version(loaderVer),
}, nil
}
func (p *Provider) Fetch(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (upstream.ResolvedPackage, error) {
if !local.HasMinecraftVersion() {
return upstream.ResolvedPackage{}, errors.New(
"fabric bootstrap requires a concrete Minecraft version",
)
}
gameVersionID := local.MinecraftVersion.String()
loaderVer, err := p.loaderVersion(ctx, id.Version)
if err != nil {
return upstream.ResolvedPackage{}, fmt.Errorf(
"resolve fabric loader version failed: %w",
err,
)
}
installerVer, err := p.latestInstallerVersion(ctx)
if err != nil {
return upstream.ResolvedPackage{}, fmt.Errorf(
"resolve fabric installer version failed: %w",
err,
)
}
url := fmt.Sprintf(
"https://meta.fabricmc.net/v2/versions/loader/%s/%s/%s/server/jar",
gameVersionID, loaderVer, installerVer,
)
return upstream.ResolvedPackage{
FileURL: url,
Filename: fmt.Sprintf(
"fabric-server-mc%s-loader%s-launcher%s.jar",
gameVersionID,
loaderVer,
installerVer,
),
}, nil
}
// LatestGameVersion reports the newest Minecraft release the loader publishes
// builds for. Fabric tracks Minecraft releases, so the entry is normally the
// current release.
func (p *Provider) LatestGameVersion(ctx context.Context) (version.Version, error) {
var versions []gameVersionEntry
if err := p.fetchVersionsMeta(ctx, "game", &versions); err != nil {
return "", err
}
for _, entry := range versions {
if entry.Stable {
return version.Version(entry.Version), nil
}
}
return "", errors.New("no stable Minecraft version in fabric metadata")
}
func (p *Provider) loaderVersion(
ctx context.Context,
selector version.Version,
) (string, error) {
if selector == version.Unknown {
return "", errors.New("unknown game version, cannot resolve fabric loader version")
}
versions, err := p.fetchLoaderVersions(ctx)
if err != nil {
return "", err
}
if len(versions) == 0 {
return "", errors.New("no fabric loader versions available")
}
switch selector {
case version.Stable:
for _, v := range versions {
if v.Stable {
return v.Version, nil
}
}
return "", errors.New("no stable fabric loader version available")
case version.Beta, version.Any:
return versions[0].Version, nil
}
for _, v := range versions {
if v.Version == selector.String() {
return v.Version, nil
}
}
return "", fmt.Errorf(
"fabric loader version %s not found",
selector.String(),
)
}
func (p *Provider) latestInstallerVersion(ctx context.Context) (string, error) {
versions, err := p.fetchInstallerVersions(ctx)
if err != nil {
return "", err
}
if len(versions) == 0 {
return "", errors.New("no fabric installer versions found")
}
return versions[0].Version, nil
}
func (p *Provider) fetchLoaderVersions(ctx context.Context) (
loaderVersions []loaderVersionEntry,
err error,
) {
err = p.fetchVersionsMeta(ctx, "loader", &loaderVersions)
return
}
func (p *Provider) fetchInstallerVersions(ctx context.Context) (
installerVersions []installerVersion,
err error,
) {
err = p.fetchVersionsMeta(ctx, "installer", &installerVersions)
return
}
func (p *Provider) fetchVersionsMeta(
ctx context.Context,
endpoint string,
target any,
) (err error) {
apiEndpoint := metaBaseURL + "/v2/versions/" + endpoint
data, err := p.network.CachedGetBytes(
ctx,
apiEndpoint,
cache.BytesRequestOptions{
Kind: cache.KindMetadata,
TTL: 3 * 24 * time.Hour,
},
)
if err != nil {
return fmt.Errorf(
"fetch fabric %s versions meta failed: %w",
endpoint, err,
)
}
err = json.Unmarshal(data, target)
if err != nil {
return fmt.Errorf(
"parse fabric %s versions meta failed: %w",
endpoint, err,
)
}
return
}
package forge
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"net/url"
"strings"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
)
const (
docsURL = "https://files.minecraftforge.net/"
promotionsURL = "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json"
mavenBaseURL = "https://maven.minecraftforge.net/net/minecraftforge/forge"
neoForgeComparisonURL = "https://docs.neoforged.net/user/docs/server"
)
// Provider resolves Forge promotions and installer artifacts over the shared
// network cache.
type Provider struct {
network *cache.Client
}
// New constructs a Forge installer provider. network supplies the cached HTTP
// transport; construction performs no requests.
func New(network *cache.Client) *Provider {
return &Provider{network: network}
}
func (p *Provider) Provider() catalog.Provider {
return catalog.ProviderForge
}
type promotions struct {
Promos map[string]string `json:"promos"`
}
// LatestMinecraftVersion reports the newest Minecraft version the promotions
// list a build for. Forge publishes release builds only.
func (p *Provider) LatestMinecraftVersion(ctx context.Context) (
version.Version,
error,
) {
body, err := p.network.CachedGetBytes(
ctx,
promotionsURL,
cache.BytesRequestOptions{Kind: cache.KindMetadata},
)
if err != nil {
return "", fmt.Errorf("fetch forge promotions failed: %w", err)
}
var data promotions
if err := json.Unmarshal(body, &data); err != nil {
return "", fmt.Errorf("parse forge promotions failed: %w", err)
}
versions := make([]version.Version, 0, len(data.Promos))
for key := range data.Promos {
minecraft, _, found := strings.Cut(key, "-")
if found && minecraft != "" {
versions = append(versions, version.Version(minecraft))
}
}
return version.NewestMinecraft(versions)
}
func (p *Provider) ResolveVersionSelector(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (catalog.VersionedRef, error) {
gameVersion, err := minecraftVersionForInstall(local)
if err != nil {
return id, err
}
forgeVersion, err := p.forgeVersionFromPackageRef(ctx, id, gameVersion)
if err != nil {
return id, err
}
return catalog.VersionedRef{
Ecosystem: catalog.EcosystemForge,
Name: "forge",
Version: version.Version(forgeVersion),
}, nil
}
func (p *Provider) Fetch(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (upstream.ResolvedPackage, error) {
gameVersion, err := minecraftVersionForInstall(local)
if err != nil {
return upstream.ResolvedPackage{}, err
}
forgeVersion, err := p.forgeVersionFromPackageRef(ctx, id, gameVersion)
if err != nil {
return upstream.ResolvedPackage{}, err
}
fileURL := installerURL(gameVersion, forgeVersion)
return upstream.ResolvedPackage{
FileURL: fileURL,
Filename: fmt.Sprintf(
"forge-%s-%s-installer.jar",
gameVersion.String(),
forgeVersion,
),
}, nil
}
func minecraftVersionForInstall(local upstream.LocalContext) (version.Version, error) {
if !local.HasMinecraftVersion() {
return "", errors.New(
"forge bootstrap requires a concrete Minecraft version",
)
}
return local.MinecraftVersion, nil
}
func (p *Provider) forgeVersionFromPackageRef(
ctx context.Context,
ref catalog.VersionedRef,
gameVersion version.Version,
) (string, error) {
if ref.Version != version.Beta &&
ref.Version != version.Stable &&
ref.Version != version.Any &&
ref.Version != version.Unknown {
return ref.Version.String(), nil
}
return p.fetchVersion(ctx, gameVersion, ref.Version)
}
// fetchVersion selects a Forge version from the `promotions` data. stable
// accepts the recommended promotion only; beta accepts the latest promotion;
// any prefers the recommended release and falls back to the latest.
func (p *Provider) fetchVersion(
ctx context.Context,
gameVersion version.Version,
selector version.Version,
) (string, error) {
body, err := p.network.CachedGetBytes(
ctx,
promotionsURL,
cache.BytesRequestOptions{Kind: cache.KindMetadata},
)
if err != nil {
return "", fmt.Errorf("fetch forge promotions failed: %w", err)
}
var data promotions
if err := json.Unmarshal(body, &data); err != nil {
return "", fmt.Errorf("parse forge promotions failed: %w", err)
}
if len(data.Promos) == 0 {
return "", fmt.Errorf("forge promotions is empty; see %s", docsURL)
}
keyBase := gameVersion.String()
recommended := data.Promos[keyBase+"-recommended"]
latest := data.Promos[keyBase+"-latest"]
switch selector {
case version.Stable:
if recommended != "" {
return recommended, nil
}
case version.Beta:
if latest != "" {
return latest, nil
}
if recommended != "" {
return recommended, nil
}
default:
if recommended != "" {
return recommended, nil
}
if latest != "" {
return latest, nil
}
}
return "", fmt.Errorf(
"no forge version found for minecraft %s in promotions data; see %s (Forge) and %s (NeoForge comparison)",
gameVersion,
docsURL,
neoForgeComparisonURL,
)
}
func installerURL(
gameVersion version.Version,
forgeVersion string,
) string {
combinedVersion := fmt.Sprintf("%s-%s", gameVersion.String(), forgeVersion)
escaped := url.PathEscape(combinedVersion)
return fmt.Sprintf(
"%s/%s/forge-%s-installer.jar",
mavenBaseURL,
escaped,
escaped,
)
}
package hangar
import (
"context"
"fmt"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
// Provider resolves Hangar projects and versions through one configured
// network cache. logger may be nil, which records nothing.
type Provider struct {
network *cache.Client
logger *log.Logger
}
// New constructs a Hangar provider. network supplies the cached HTTP
// transport; construction performs no requests.
func New(network *cache.Client, logger *log.Logger) *Provider {
return &Provider{network: network, logger: logger}
}
func (p *Provider) Provider() catalog.Provider {
return catalog.ProviderHangar
}
// Builds lists every published version. Hangar versions declare supported
// platforms independently, so each platform becomes its own build entry.
func (p *Provider) Builds(ctx context.Context, project string) ([]upstream.Build, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
versions, err := p.listVersions(ctx, catalog.Name(project))
if err != nil {
return nil, err
}
var builds []upstream.Build
for i := range versions {
builds = append(builds, versions[i].ToBuilds()...)
}
return builds, nil
}
func (p *Provider) Search(ctx context.Context, q upstream.Query) (upstream.SearchResponse, error) {
// Hangar publishes server plugins only; IncludeClient needs no filter.
res, err := p.searchProjects(ctx, q.Keyword, q.FilterEcosystem)
if err != nil {
return upstream.SearchResponse{}, err
}
return res.ToSearchResults(p.Provider()), nil
}
func (p *Provider) Fetch(
ctx context.Context,
_ upstream.LocalContext,
id catalog.VersionedRef,
) (upstream.ResolvedPackage, error) {
version, err := p.getVersion(ctx, id)
if err != nil {
return upstream.ResolvedPackage{}, err
}
preferredPlatform := preferredDownloadPlatform(id.Ecosystem)
if remote, ok := version.ToPackageRemoteForPlatform(preferredPlatform); ok {
return upstream.ResolvedPackage{
FileURL: remote.FileURL,
Filename: remote.Filename,
Hash: remote.Hash,
HashAlgorithm: remote.HashAlgorithm,
}, nil
}
if remote := version.ToPackageRemote(); remote.FileURL != "" {
return upstream.ResolvedPackage{
FileURL: remote.FileURL,
Filename: remote.Filename,
Hash: remote.Hash,
HashAlgorithm: remote.HashAlgorithm,
}, nil
}
return upstream.ResolvedPackage{}, ErrNoDownload
}
func (p *Provider) Info(ctx context.Context, ref catalog.Ref) (catalog.Metadata, error) {
project, err := p.getProject(ctx, ref.Name)
if err != nil {
return catalog.Metadata{}, err
}
info := project.ToProjectInformation()
return info, nil
}
func (p *Provider) Dependencies(
ctx context.Context,
_ upstream.LocalContext,
id catalog.VersionedRef,
) (*upstream.DependencySet, error) {
version, err := p.getVersion(ctx, id)
if err != nil {
return nil, fmt.Errorf("hangar: dependencies fetch failed: %w", err)
}
return new(
(&hangarDependencies{
version: version,
platform: id.Ecosystem,
}).ToDependencySet(),
), nil
}
func (p *Provider) ResolveVersionSelector(
ctx context.Context,
_ upstream.LocalContext,
id catalog.VersionedRef,
) (parsed catalog.VersionedRef, err error) {
if id.Ecosystem.IsUnspecified() {
id.Ecosystem = catalog.EcosystemUnspecified
}
if !id.Version.CanInfer() {
return id, nil
}
version, err := p.resolveVersion(ctx, id)
if err != nil {
return id, err
}
parsed = id
parsed.Version = versionpkg.Version(version.Name)
return parsed, nil
}
package hangar
import (
"maps"
"slices"
"strings"
"time"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/upstream"
)
const hangarSiteBaseURL = "https://hangar.papermc.io"
type hangarProjectRef struct {
Owner string
Slug string
}
func (p hangarProject) ProjectRef() hangarProjectRef {
return p.Namespace.ProjectRef()
}
func (n hangarProjectNamespace) ProjectRef() hangarProjectRef {
return hangarProjectRef{Owner: n.Owner, Slug: n.Slug}
}
func (r hangarProjectRef) CanonicalName() catalog.Name {
return input.ToProjectName(r.Slug)
}
func (r hangarProjectRef) LookupPath() string {
if r.Owner == "" {
return r.Slug
}
return r.Owner + "/" + r.Slug
}
func (r hangarProjectRef) ProjectURL() string {
if r.Owner == "" || r.Slug == "" {
return ""
}
return hangarSiteBaseURL + "/" + r.Owner + "/" + r.Slug
}
func (s *projectSearchResponse) ToSearchResults(source catalog.Provider) upstream.SearchResponse {
res := upstream.SearchResponse{
Provider: source,
Items: make([]upstream.SearchResult, 0, len(s.Result)),
}
for _, project := range s.Result {
res.Items = append(
res.Items, upstream.SearchResult{
ProjectName: project.ProjectRef().CanonicalName().String(),
Provider: source,
},
)
}
return res
}
func (p *hangarProject) ToProjectInformation() catalog.Metadata {
info := catalog.Metadata{
Title: p.Name,
Brief: p.Description,
Description: p.MainPageContent,
DescriptionIsMarkdown: p.MainPageContent != "" && (upstream.LooksLikeMarkdown(p.MainPageContent) || strings.Contains(
p.MainPageContent,
"#",
)),
License: firstNonEmpty(
p.Settings.License.Name,
p.Settings.License.Type,
),
Authors: make([]catalog.Person, 0, len(p.MemberNames)),
URLs: make([]catalog.URL, 0, len(p.Settings.Links)+1),
}
if projectURL := p.ProjectRef().ProjectURL(); projectURL != "" {
info.URLs = append(
info.URLs, catalog.URL{
Name: "Hangar",
Type: catalog.URLHome,
URL: projectURL,
},
)
}
for _, memberName := range p.MemberNames {
info.Authors = append(info.Authors, catalog.Person{Name: memberName})
}
for _, section := range p.Settings.Links {
for _, link := range section.Links {
if link.URL == "" {
continue
}
info.URLs = append(
info.URLs, catalog.URL{
Name: link.Name,
Type: classifyHangarURL(link.Name),
URL: link.URL,
},
)
}
}
return info
}
// ToBuilds maps one Hangar version to one build entry per declared platform.
// Game versions stay attached to the platform that declared them.
func (v *hangarVersion) ToBuilds() []upstream.Build {
channel := strings.ToLower(strings.TrimSpace(v.Channel.Name))
published := hangarPublishedAt(v.CreatedAt)
platforms := make(map[string]struct{})
for platform := range v.PlatformDependencies {
platforms[strings.ToLower(platform)] = struct{}{}
}
for platform := range v.Downloads {
platforms[strings.ToLower(platform)] = struct{}{}
}
if len(platforms) == 0 {
return []upstream.Build{{
Version: v.Name,
Channel: channel,
Published: published,
}}
}
builds := make([]upstream.Build, 0, len(platforms))
for _, platform := range sortedMapKeys(platforms) {
builds = append(
builds, upstream.Build{
Version: v.Name,
GameVersions: v.platformGameVersions(platform),
Loaders: []string{platform},
Channel: channel,
Published: published,
},
)
}
return builds
}
func (v *hangarVersion) platformGameVersions(platform string) []string {
for key, versions := range v.PlatformDependencies {
if strings.EqualFold(key, platform) {
return versions
}
}
return nil
}
func hangarPublishedAt(raw string) time.Time {
published, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}
}
return published
}
func (v *hangarVersion) ToPackageRemote() upstream.ResolvedPackage {
remote, _ := v.ToPackageRemoteForPlatform(preferredDownloadPlatform(catalog.EcosystemUnspecified))
if remote.FileURL == "" {
platforms := sortedMapKeys(v.Downloads)
if len(platforms) == 0 {
return upstream.ResolvedPackage{ID: catalog.VersionedRef{Ref: catalog.Ref{Provider: catalog.ProviderHangar}}}
}
remote, _ = v.ToPackageRemoteForPlatform(catalog.Ecosystem(strings.ToLower(platforms[0])))
}
return remote
}
func (v *hangarVersion) ToPackageRemoteForPlatform(platform catalog.Ecosystem) (
upstream.ResolvedPackage,
bool,
) {
download, ok := v.downloadForPlatform(platform)
if !ok {
return upstream.ResolvedPackage{ID: catalog.VersionedRef{Ref: catalog.Ref{Provider: catalog.ProviderHangar}}}, false
}
remote := upstream.ResolvedPackage{
ID: catalog.VersionedRef{Ref: catalog.Ref{Provider: catalog.ProviderHangar}},
FileURL: download.URL(),
Filename: download.FileInfo.Name,
}
if download.FileInfo.SHA256Hash != "" {
remote.Hash = download.FileInfo.SHA256Hash
remote.HashAlgorithm = "sha256"
}
return remote, true
}
// IsReleaseChannel reports whether the version was published on Hangar's
// Release channel. A version without channel data is treated as a release so
// older cached payloads keep resolving.
func (v *hangarVersion) IsReleaseChannel() bool {
return v.Channel.Name == "" || strings.EqualFold(v.Channel.Name, "Release")
}
func (v *hangarVersion) PluginDependencyNames() []catalog.Name {
depsForPlatform := v.DependenciesForPlatform(catalog.EcosystemUnspecified)
if len(depsForPlatform) == 0 {
return nil
}
deps := make([]catalog.Name, 0, len(depsForPlatform))
for _, dep := range depsForPlatform {
if dep.Name == "" || dep.ExternalURL != nil {
continue
}
deps = append(deps, input.ToProjectName(dep.Name))
}
slices.Sort(deps)
return deps
}
func (v *hangarVersion) DependenciesForPlatform(platform catalog.Ecosystem) []hangarPluginDependency {
if len(v.PluginDependencies) == 0 {
return nil
}
preferredKey := strings.ToUpper(preferredDownloadPlatform(platform).String())
if deps := v.PluginDependencies[preferredKey]; len(deps) > 0 {
return deps
}
for _, key := range sortedMapKeys(v.PluginDependencies) {
if deps := v.PluginDependencies[key]; len(deps) > 0 {
return deps
}
}
return nil
}
func (v *hangarVersion) HasDownloadForPlatform(platform catalog.Ecosystem) bool {
_, ok := v.downloadForPlatform(preferredDownloadPlatform(platform))
if ok {
return true
}
return len(v.Downloads) > 0 && platform == catalog.EcosystemUnspecified
}
func (v *hangarVersion) SupportsPlatform(platform catalog.Ecosystem) bool {
if len(v.PlatformDependencies) == 0 {
return false
}
preferredKey := strings.ToUpper(preferredDownloadPlatform(platform).String())
if versions := v.PlatformDependencies[preferredKey]; len(versions) > 0 {
return true
}
return platform == catalog.EcosystemUnspecified && len(v.PlatformDependencies) > 0
}
func (v *hangarVersion) downloadForPlatform(platform catalog.Ecosystem) (
hangarDownload,
bool,
) {
if len(v.Downloads) == 0 {
return hangarDownload{}, false
}
needle := strings.ToLower(platform.String())
for key, download := range v.Downloads {
if strings.ToLower(key) == needle {
return download, true
}
}
return hangarDownload{}, false
}
func (d hangarDownload) URL() string {
if d.ExternalURL != nil && *d.ExternalURL != "" {
return *d.ExternalURL
}
return d.DownloadURL
}
func classifyHangarURL(name string) catalog.URLType {
switch strings.ToLower(name) {
case "issues":
return catalog.URLIssues
case "source":
return catalog.URLSource
case "wiki":
return catalog.URLWiki
case "support", "discord":
return catalog.URLForum
case "website", "homepage", "hangar":
return catalog.URLHome
default:
return catalog.URLMisc
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func sortedMapKeys[T any](m map[string]T) []string {
return slices.Sorted(maps.Keys(m))
}
package hangar
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
)
const hangarAPIBaseURL = "https://hangar.papermc.io/api/v1"
var (
ErrInvalidAPIResponse = errors.New("hangar: invalid api response")
ErrNoProject = errors.New("hangar: project not found")
ErrNoVersion = errors.New("hangar: version not found")
ErrNoDownload = errors.New("hangar: download not found")
)
func (p *Provider) getProject(
ctx context.Context,
name catalog.Name,
) (*hangarProject, error) {
if project, err := p.getProjectByPath(ctx, string(name)); err == nil {
if project.ProjectRef().CanonicalName() == name {
return project, nil
}
}
search, err := p.searchProjects(
ctx,
string(name),
catalog.EcosystemUnspecified,
)
if err != nil {
return nil, err
}
for _, project := range search.Result {
if project.ProjectRef().CanonicalName() == name {
return p.getProjectByRef(ctx, project.ProjectRef())
}
}
return nil, ErrNoProject
}
func (p *Provider) getProjectByRef(
ctx context.Context,
ref hangarProjectRef,
) (*hangarProject, error) {
if ref.Owner == "" || ref.Slug == "" {
return nil, ErrNoProject
}
return p.getProjectByPath(ctx, ref.LookupPath())
}
func (p *Provider) getProjectByPath(
ctx context.Context,
path string,
) (*hangarProject, error) {
project := &hangarProject{}
if err := p.getJSON(ctx, hangarProjectURL(path), project); err != nil {
return nil, err
}
return project, nil
}
func (p *Provider) searchProjects(
ctx context.Context,
query string,
ecosystem catalog.Ecosystem,
) (*projectSearchResponse, error) {
params := url.Values{}
if query != "" {
params.Set("query", query)
}
params.Set("limit", "25")
if eco := searchEcosystem(ecosystem); eco != "" {
params.Set("platform", eco)
}
res := &projectSearchResponse{}
if err := p.getJSON(ctx, hangarProjectsURL(params), res); err != nil {
return nil, err
}
return res, nil
}
func (p *Provider) getVersion(
ctx context.Context,
id catalog.VersionedRef,
) (*hangarVersion, error) {
project, err := p.getProject(ctx, id.Name)
if err != nil {
return nil, err
}
version := &hangarVersion{}
if err := p.getJSON(
ctx,
hangarVersionURL(
project.ProjectRef(),
id.Version.String(),
), version,
); err != nil {
return nil, err
}
return version, nil
}
func (p *Provider) listVersions(
ctx context.Context,
name catalog.Name,
) ([]hangarVersion, error) {
project, err := p.getProject(ctx, name)
if err != nil {
return nil, err
}
// Hangar caps a versions page at 25 entries, so walk every page to keep
// both resolution and build listing complete.
const pageSize = 25
var versions []hangarVersion
for offset := 0; ; {
params := url.Values{}
params.Set("limit", strconv.Itoa(pageSize))
params.Set("offset", strconv.Itoa(offset))
res := &VersionListResponse{}
if err := p.getJSON(
ctx,
hangarVersionsURL(project.ProjectRef(), params),
res,
); err != nil {
return nil, err
}
versions = append(versions, res.Result...)
offset += len(res.Result)
if len(res.Result) == 0 || offset >= res.Pagination.Count {
break
}
}
if len(versions) == 0 {
return nil, ErrNoVersion
}
return versions, nil
}
func (p *Provider) getJSON(
ctx context.Context,
rawURL string,
out any,
) error {
p.logger.Debug("hangar request: " + rawURL)
res, err := p.network.CachedGetRequest(
ctx,
rawURL,
cache.BytesRequestOptions{Kind: cache.KindMetadata},
)
if err != nil {
return fmt.Errorf("hangar: request failed: %w", err)
}
if res.StatusCode == http.StatusNotFound {
return ErrNoProject
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("hangar: unexpected status %d", res.StatusCode)
}
if err := json.Unmarshal(res.Data, out); err != nil {
return fmt.Errorf("hangar: failed to decode response: %w", err)
}
return nil
}
func hangarProjectsURL(params url.Values) string {
return withQuery(hangarAPIBaseURL+"/projects", params)
}
func hangarProjectURL(path string) string {
return hangarAPIBaseURL + "/projects/" + strings.TrimPrefix(path, "/")
}
func hangarVersionsURL(ref hangarProjectRef, params url.Values) string {
return withQuery(hangarProjectURL(ref.LookupPath())+"/versions", params)
}
func hangarVersionURL(ref hangarProjectRef, version string) string {
return hangarProjectURL(ref.LookupPath()) + "/versions/" + url.PathEscape(version)
}
func withQuery(base string, params url.Values) string {
if len(params) == 0 {
return base
}
return base + "?" + params.Encode()
}
func searchEcosystem(ecosystem catalog.Ecosystem) string {
switch ecosystem {
case catalog.EcosystemBukkit, catalog.EcosystemPaper:
return hangarPreferredPlatform
case catalog.EcosystemUnspecified:
return hangarPreferredPlatform
default:
return ""
}
}
package hangar
import (
"context"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
const hangarPreferredPlatform = "PAPER"
type hangarDependencies struct {
version *hangarVersion
platform catalog.Ecosystem
}
func (h *hangarDependencies) ToDependencySet() upstream.DependencySet {
result := upstream.DependencySet{FromArtifact: true}
for _, dep := range h.version.DependenciesForPlatform(h.platform) {
if dep.Name == "" || dep.ExternalURL != nil {
continue
}
result.Dependencies = append(
result.Dependencies, catalog.Dependency{
ID: catalog.VersionedRef{
Ref: catalog.Ref{
Name: input.ToProjectName(dep.Name),
Provider: catalog.ProviderUnspecified,
},
Ecosystem: catalog.EcosystemUnspecified,
},
Mandatory: dep.Required,
},
)
}
return result
}
func (p *Provider) resolveVersion(
ctx context.Context,
id catalog.VersionedRef,
) (*hangarVersion, error) {
versions, err := p.listVersions(ctx, id.Name)
if err != nil {
return nil, err
}
switch id.Version {
case versionpkg.Any, versionpkg.Beta:
return selectLatestVersion(versions, id.Ecosystem, id.Version)
case versionpkg.Stable:
return selectLatestCompatibleVersion(versions, id.Ecosystem)
default:
for i := range versions {
if versions[i].Name == id.Version.String() {
return &versions[i], nil
}
}
return nil, ErrNoVersion
}
}
// selectLatestVersion selects the newest version for any/beta selectors.
// any prefers a release channel and falls back to any channel; beta accepts
// any channel. Platform-specific candidates are preferred over versions that
// declare no platform dependency.
func selectLatestVersion(
versions []hangarVersion,
platform catalog.Ecosystem,
selector versionpkg.Version,
) (*hangarVersion, error) {
platforms := []catalog.Ecosystem{platform}
if platform != catalog.EcosystemUnspecified {
platforms = append(platforms, catalog.EcosystemUnspecified)
}
for _, candidatePlatform := range platforms {
if selector == versionpkg.Any {
if version := firstVersionMatching(
versions,
candidatePlatform,
false,
true,
); version != nil {
return version, nil
}
}
if version := firstVersionMatching(
versions,
candidatePlatform,
false,
false,
); version != nil {
return version, nil
}
}
return nil, ErrNoVersion
}
// selectLatestCompatibleVersion selects the newest release-channel version
// that declares compatibility with the platform. stable never falls back to
// a prerelease channel.
func selectLatestCompatibleVersion(
versions []hangarVersion,
platform catalog.Ecosystem,
) (*hangarVersion, error) {
if version := firstVersionMatching(
versions,
platform,
true,
true,
); version != nil {
return version, nil
}
return nil, ErrNoVersion
}
func firstVersionMatching(
versions []hangarVersion,
platform catalog.Ecosystem,
requireCompatibility bool,
requireRelease bool,
) *hangarVersion {
for i := range versions {
version := &versions[i]
if !version.HasDownloadForPlatform(platform) {
continue
}
if requireCompatibility && !version.SupportsPlatform(platform) {
continue
}
if requireRelease && !version.IsReleaseChannel() {
continue
}
return version
}
return nil
}
func preferredDownloadPlatform(platform catalog.Ecosystem) catalog.Ecosystem {
if platform == catalog.EcosystemUnspecified {
return "paper"
}
return "paper"
}
package mcdr
import (
"context"
"fmt"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/github"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
)
// Provider resolves MCDR plugin catalogue entries through one configured
// network cache and GitHub client. logger may be nil, which records nothing.
type Provider struct {
network *cache.Client
logger *log.Logger
githubClient *github.Client
}
// New constructs an MCDR provider. network supplies the cached HTTP transport;
// construction performs no requests.
func New(network *cache.Client, logger *log.Logger) *Provider {
return &Provider{
network: network,
logger: logger,
githubClient: github.New(network, logger),
}
}
func (p *Provider) Provider() catalog.Provider {
return catalog.ProviderMCDR
}
// Builds lists every release in the plugin catalogue release history.
func (p *Provider) Builds(ctx context.Context, project string) ([]upstream.Build, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
history, err := p.getReleaseHistory(ctx, catalog.Name(project).PythonName())
if err != nil {
return nil, err
}
builds := make([]upstream.Build, 0, len(history.Releases))
for _, rel := range history.Releases {
builds = append(builds, rel.ToBuild())
}
return builds, nil
}
type mcdrSearchResult []string
func (m mcdrSearchResult) ToSearchResults(source catalog.Provider) upstream.SearchResponse {
res := upstream.SearchResponse{Provider: source}
for _, id := range m {
res.Items = append(
res.Items, upstream.SearchResult{
ProjectName: input.ToProjectName(id).String(),
Provider: source,
},
)
}
return res
}
// Search returns the provider's default result set; tags and limit are not
// applied.
func (p *Provider) Search(ctx context.Context, q upstream.Query) (upstream.SearchResponse, error) {
// MCDR publishes server plugins only; IncludeClient needs no filter.
if q.FilterEcosystem != catalog.EcosystemMCDR && q.FilterEcosystem != catalog.EcosystemUnspecified {
return upstream.SearchResponse{}, fmt.Errorf(
"invalid search platform: expected %s, got %s",
catalog.EcosystemMCDR,
q.FilterEcosystem,
)
}
res, err := p.search(ctx, q.Keyword)
if err != nil {
return upstream.SearchResponse{}, err
}
return res.ToSearchResults(p.Provider()), nil
}
func (p *Provider) Fetch(
ctx context.Context,
_ upstream.LocalContext,
id catalog.VersionedRef,
) (upstream.ResolvedPackage, error) {
rel, err := p.getRelease(ctx, id.Name.PythonName(), id.Version)
if err != nil {
return upstream.ResolvedPackage{}, err
}
r := rel.ToPackageRemote()
return upstream.ResolvedPackage{
FileURL: r.FileURL,
Filename: r.Filename,
Hash: r.Hash,
HashAlgorithm: r.HashAlgorithm,
}, nil
}
func (p *Provider) Info(ctx context.Context, ref catalog.Ref) (catalog.Metadata, error) {
name := ref.Name.PythonName()
plugin, err := p.getInfo(ctx, name)
if err != nil {
return catalog.Metadata{}, err
}
meta, err := p.getMeta(ctx, name)
if err != nil {
return catalog.Metadata{}, err
}
repo, err := p.getRepository(ctx, name)
if err != nil {
return catalog.Metadata{}, err
}
info := rawProjectInformation{
Info: plugin,
Meta: meta,
Repository: repo,
}.ToProjectInformation()
return info, nil
}
func (p *Provider) Dependencies(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (*upstream.DependencySet, error) {
if id.Version == "" || id.Version.CanInfer() {
resolved, err := p.ResolveVersionSelector(ctx, local, id)
if err != nil {
return nil, err
}
id = resolved
}
rel, err := p.getRelease(ctx, id.Name.PythonName(), id.Version)
if err != nil {
return nil, err
}
dependencies, err := mcdrDependenciesFromMeta(rel.Meta)
if err != nil {
return nil, err
}
return new(dependencies), nil
}
func mcdrDependenciesFromMeta(meta pluginMeta) (upstream.DependencySet, error) {
deps := upstream.DependencySet{FromArtifact: true}
if len(meta.Dependencies) == 0 {
return deps, nil
}
deps.Dependencies = make([]catalog.Dependency, 0, len(meta.Dependencies))
for name, constraint := range meta.Dependencies {
parsed, err := version.ParseConstraint(
constraint,
version.DialectMCDR,
)
if err != nil {
return upstream.DependencySet{}, err
}
deps.Dependencies = append(
deps.Dependencies, catalog.Dependency{
ID: catalog.VersionedRef{
Ref: catalog.Ref{
Name: input.ToProjectName(name),
Provider: catalog.ProviderMCDR,
},
Ecosystem: catalog.EcosystemMCDR,
},
Constraint: parsed,
Mandatory: true,
},
)
}
return deps, nil
}
func (p *Provider) ResolveVersionSelector(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (parsed catalog.VersionedRef, err error) {
var rel *release
switch id.Version {
case version.Stable:
rel, err = p.getLatestCompatibleRelease(
ctx,
id.Name.PythonName(),
local.MCDRVersion,
)
case version.Beta, version.Any:
rel, err = p.getLatestRelease(ctx, id.Name.PythonName())
if err != nil {
return id, err
}
default:
return id, fmt.Errorf(
"cannot parse version %s for package %s",
id.Version,
id.Name,
)
}
if err != nil {
return id, err
}
parsed = catalog.VersionedRef{
Ecosystem: catalog.EcosystemMCDR,
Name: id.Name,
Version: version.Version(rel.Meta.Version),
}
p.logger.Debug("parsed from" + id.StringQualified() + " to " + parsed.StringQualified())
return parsed, nil
}
package mcdr
import (
"context"
"fmt"
"strconv"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
)
func (p *Provider) Candidates(ctx context.Context, project, _ string, loader catalog.Ecosystem) ([]upstream.Candidate, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if loader != catalog.EcosystemMCDR {
return nil, fmt.Errorf("MCDR catalogue requires the mcdr loader")
}
history, err := p.getReleaseHistory(ctx, project)
if err != nil {
return nil, err
}
out := make([]upstream.Candidate, 0, len(history.Releases))
for _, rel := range history.Releases {
hashes := map[string]string{}
if rel.Asset.HashSha256 != "" {
hashes["sha256"] = rel.Asset.HashSha256
}
channel := rel.Channel()
projectID := history.ID
if projectID == "" {
projectID = project
}
c := upstream.Candidate{
Provider: catalog.ProviderMCDR,
ProjectID: projectID,
ReleaseID: rel.TagName,
FileID: strconv.Itoa(rel.Asset.ID),
Version: version.Version(rel.Meta.Version),
Filename: rel.Asset.Name,
URL: rel.Asset.BrowserDownloadUrl,
Hashes: hashes,
Channel: channel,
PublishedAt: rel.CreatedAt,
PrimaryFile: true,
Dependencies: []upstream.ProjectDependency{},
}
for id := range rel.Meta.Dependencies {
if id != "mcdreforged" {
c.Dependencies = append(c.Dependencies, upstream.ProjectDependency{
Provider: catalog.ProviderMCDR,
ProjectID: id,
Kind: catalog.DependencyKindRequired,
})
}
}
out = append(out, c)
}
if len(out) == 0 {
return nil, fmt.Errorf("MCDR catalogue project %s has no releases", project)
}
return out, nil
}
package mcdr
import "errors"
var (
ErrorGhApi = errors.New("error from GitHub API")
ErrPluginNotFound = func(id string) error { return errors.New("plugin not found: " + id) }
ErrVersionNotFound = func(id string, version string) error {
return errors.New("version not found: " + version + " for plugin " + id)
}
)
package mcdr
import (
"github.com/mclucy/lucy/version"
)
func selectLatestRelease(history *pluginRelease) *release {
if history == nil || len(history.Releases) == 0 {
return nil
}
return &history.Releases[history.LatestVersionIndex]
}
func selectLatestCompatibleRelease(
history *pluginRelease,
localMcdrVersion version.Version,
) (*release, error) {
if history == nil {
return nil, nil
}
dialect, err := version.NewEngine(version.DialectMCDR)
if err != nil {
return nil, err
}
localVersion, err := dialect.ParseVersion(localMcdrVersion)
if err != nil {
return nil, err
}
for i := range history.Releases {
rel := &history.Releases[i]
if rel.Prerelease {
continue
}
rangeExpr, ok := rel.Meta.Dependencies["mcdreforged"]
if !ok {
continue
}
constraint, err := dialect.ParseConstraint(rangeExpr)
if err != nil {
return nil, err
}
if dialect.Match(constraint, localVersion) {
return rel, nil
}
}
return nil, nil
}
package mcdr
import (
"context"
"encoding/json/v2"
"fmt"
"github.com/mclucy/lucy/github"
versionpkg "github.com/mclucy/lucy/version"
"github.com/sahilm/fuzzy"
)
const (
pluginCatalogueRepoEndpoint = `https://api.github.com/repos/MCDReforged/PluginCatalogue/contents/`
branchMaster = "?ref=master"
branchCatalogue = "?ref=catalogue"
branchMeta = "?ref=meta"
)
func (p *Provider) search(
ctx context.Context,
query string,
) (mcdrSearchResult, error) {
ghEndpoint := pluginCatalogueRepoEndpoint + "plugins/" + branchCatalogue
err, msg, items := p.githubClient.GetDirectoryFromGitHub(ctx, ghEndpoint)
if err != nil {
return nil, err
}
if msg != nil && msg.Message != "" {
return nil, fmt.Errorf("%w: %s", ErrorGhApi, msg.Message)
}
pluginIds := make([]string, 0)
for _, file := range items {
pluginIds = append(pluginIds, file.Name)
}
matches := fuzzy.Find(query, pluginIds)
result := make([]string, 0, len(matches))
for _, match := range matches {
result = append(result, pluginIds[match.Index])
}
return result, nil
}
func (p *Provider) getInfo(
ctx context.Context,
id string,
) (*pluginInfo, error) {
ghEndpoint := pluginCatalogueRepoEndpoint + "plugins/" + id + "/plugin_info.json" + branchMaster
var data []byte
err, msg, data := p.githubClient.GetFileFromGitHub(ctx, ghEndpoint)
if err != nil {
return nil, err
}
if msg != nil && msg.Message != "" {
if msg.Status == "404" {
return nil, ErrPluginNotFound(id)
}
return nil, fmt.Errorf("%w: %s", ErrorGhApi, msg.Message)
}
var res github.GhItem
err = json.Unmarshal(data, &res)
if err != nil {
return nil, err
}
var info pluginInfo
err = json.Unmarshal(data, &info)
if err != nil {
return nil, err
}
return &info, nil
}
func (p *Provider) getMeta(
ctx context.Context,
id string,
) (*pluginMeta, error) {
ghEndpoint := pluginCatalogueRepoEndpoint + id + "/meta.json" + branchMeta
err, msg, data := p.githubClient.GetFileFromGitHub(ctx, ghEndpoint)
if err != nil {
return nil, err
}
if msg != nil && msg.Message != "" {
if msg.Status == "404" {
return nil, ErrPluginNotFound(id)
}
return nil, fmt.Errorf("%w: %s", ErrorGhApi, msg.Message)
}
var meta pluginMeta
err = json.Unmarshal(data, &meta)
if err != nil {
return nil, err
}
return &meta, nil
}
func (p *Provider) getRelease(
ctx context.Context,
id string,
version versionpkg.Version,
) (*release, error) {
history, err := p.getReleaseHistory(ctx, id)
if err != nil {
return nil, err
}
if version == versionpkg.Any || version == versionpkg.Beta {
return &history.Releases[history.LatestVersionIndex], nil
}
for _, rel := range history.Releases {
if rel.Meta.Version == version.String() {
return &rel, nil
}
}
return nil, ErrVersionNotFound(id, version.String())
}
func (p *Provider) getLatestRelease(
ctx context.Context,
id string,
) (*release, error) {
history, err := p.getReleaseHistory(ctx, id)
if err != nil {
return nil, err
}
rel := selectLatestRelease(history)
if rel == nil {
return nil, ErrVersionNotFound(id, versionpkg.Any.String())
}
return rel, nil
}
func (p *Provider) getLatestCompatibleRelease(
ctx context.Context,
id string,
localMcdrVersion versionpkg.Version,
) (*release, error) {
history, err := p.getReleaseHistory(ctx, id)
if err != nil {
return nil, err
}
rel, err := selectLatestCompatibleRelease(history, localMcdrVersion)
if err != nil {
return nil, err
}
if rel != nil {
return rel, nil
}
return nil, ErrVersionNotFound(id, "latest compatible")
}
func (p *Provider) getReleaseHistory(
ctx context.Context,
id string,
) (*pluginRelease, error) {
ghEndpoint := pluginCatalogueRepoEndpoint + id + "/release.json" + branchMeta
err, msg, data := p.githubClient.GetFileFromGitHub(ctx, ghEndpoint)
if err != nil {
return nil, err
}
if msg != nil && msg.Message != "" {
if msg.Status == "404" {
return nil, ErrPluginNotFound(id)
}
return nil, fmt.Errorf("%w: %s", ErrorGhApi, msg.Message)
}
var releaseHistory pluginRelease
err = json.Unmarshal(data, &releaseHistory)
if err != nil {
return nil, err
}
return &releaseHistory, nil
}
func (p *Provider) getRepository(
ctx context.Context,
id string,
) (*pluginRepo, error) {
ghEndpoint := pluginCatalogueRepoEndpoint + id + "/repository.json" + branchMeta
err, msg, data := p.githubClient.GetFileFromGitHub(ctx, ghEndpoint)
if err != nil {
return nil, err
}
if msg != nil && msg.Message != "" {
if msg.Status == "404" {
return nil, ErrPluginNotFound(id)
}
return nil, fmt.Errorf("%w: %s", ErrorGhApi, msg.Message)
}
var repo pluginRepo
err = json.Unmarshal(data, &repo)
if err != nil {
return nil, err
}
return &repo, nil
}
package mcdr
import (
"time"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/internal/fn"
"github.com/mclucy/lucy/upstream"
)
// SourceGitHub API file ref: https://api.github.com/repos/MCDReforged/PluginCatalogue/contents/plugins/{plugin_name}/plugin_info.json
// Lucy uses this file only for authorship; metadata comes from meta.json on
// the meta branch instead.
type pluginInfo struct {
ID string `json:"id"`
Authors []author `json:"authors"`
Repository string `json:"repository"`
Branch string `json:"branch"`
RelatedPath string `json:"related_path"`
Labels []string `json:"labels"`
Introduction struct {
EnUs string `json:"en_us"`
ZhCn string `json:"zh_cn"`
} `json:"introduction"`
}
type author struct {
Name string `json:"name"`
Link string `json:"link"`
}
// SourceGitHub API file ref: https://api.github.com/repos/MCDReforged/PluginCatalogue/{plugin_name}/release.json?ref=meta
type pluginRelease struct {
SchemaVersion int `json:"schema_version"`
ID string `json:"id"`
LatestVersion string `json:"latest_version"`
LatestVersionIndex int `json:"latest_version_index"`
Releases []release `json:"releases"`
}
type release struct {
URL string `json:"url"`
Name string `json:"name"`
TagName string `json:"tag_name"`
CreatedAt time.Time `json:"created_at"`
Description string `json:"description"`
Prerelease bool `json:"prerelease"`
Asset asset `json:"asset"`
Meta pluginMeta `json:"meta"`
}
func (r release) ToPackageRemote() upstream.ResolvedPackage {
remote := upstream.ResolvedPackage{
ID: catalog.VersionedRef{Ref: catalog.Ref{Provider: catalog.ProviderMCDR}},
FileURL: r.Asset.BrowserDownloadUrl,
Filename: r.Asset.Name,
}
return remote
}
// Channel reports the stability channel from the release's prerelease flag.
func (r release) Channel() upstream.ReleaseChannel {
if r.Prerelease {
return upstream.ReleaseChannelBeta
}
return upstream.ReleaseChannelRelease
}
// ToBuild maps a catalogue release to its MCDR compatibility.
func (r release) ToBuild() upstream.Build {
return upstream.Build{
Version: r.Meta.Version,
Loaders: []string{"mcdr"},
Channel: string(r.Channel()),
Published: r.CreatedAt,
}
}
type asset struct {
ID int `json:"id"`
Name string `json:"name"`
Size int `json:"size"`
DownloadCount int `json:"download_count"`
CreatedAt time.Time `json:"created_at"`
BrowserDownloadUrl string `json:"browser_download_url"`
HashMd5 string `json:"hash_md5"`
HashSha256 string `json:"hash_sha256"`
}
// SourceGitHub API file ref: https://api.github.com/repos/MCDReforged/PluginCatalogue/contents/{plugin_name}/meta.json?ref=meta
type pluginMeta struct {
SchemaVersion int `json:"schema_version"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Link string `json:"link"`
Authors []string `json:"authors"`
Dependencies map[string]string `json:"dependencies"`
Requirements []string `json:"requirements"`
Description struct {
EnUs string `json:"en_us"`
ZhCn string `json:"zh_cn"`
} `json:"description"`
}
// SourceGitHub API file ref: https://api.github.com/repos/MCDReforged/PluginCatalogue/contents/{plugin_name}/repository.json?ref=meta
type pluginRepo struct {
URL string `json:"url"`
Name string `json:"name"`
FullName string `json:"full_name"`
HtmlUrl string `json:"html_url"`
Description string `json:"description"`
Archived bool `json:"archived"`
StargazersCount int `json:"stargazers_count"`
WatchersCount int `json:"watchers_count"`
ForksCount int `json:"forks_count"`
Readme string `json:"readme"`
ReadmeUrl string `json:"readme_url"`
License *struct {
Key string `json:"key"`
Name string `json:"name"`
SpdxId string `json:"spdx_id"`
URL string `json:"url"`
} `json:"license"`
}
type rawProjectInformation struct {
Info *pluginInfo
Meta *pluginMeta
Repository *pluginRepo
}
func (r rawProjectInformation) ToProjectInformation() catalog.Metadata {
info := catalog.Metadata{
Title: r.Meta.Name,
Brief: r.Meta.Description.EnUs,
Description: r.Repository.Readme,
DescriptionURL: r.Repository.HtmlUrl,
DescriptionIsMarkdown: true,
Authors: nil,
URLs: nil,
License: fn.Ternary(
r.Repository.License != nil,
r.Repository.License.Name,
"n/a",
),
}
info.Authors = make([]catalog.Person, 0)
for _, author := range r.Info.Authors {
info.Authors = append(
info.Authors, catalog.Person{
Name: author.Name,
URL: author.Link,
},
)
}
info.URLs = make([]catalog.URL, 0)
info.URLs = append(
info.URLs,
catalog.URL{
Name: "Plugin Page",
Type: catalog.URLHome,
URL: r.Meta.Link,
}, catalog.URL{
Name: "SourceGitHub Repo",
Type: catalog.URLSource,
URL: r.Info.Repository,
},
)
return info
}
// Package modrinth provides functions to interact with Modrinth API.
//
// Terminology: a Modrinth project maps to a Lucy project, and a Modrinth
// version maps to a Lucy package version. Lucy-side names are Modrinth slugs;
// private helpers say "slug" for the project and "version" for a release,
// beta, or alpha.
package modrinth
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"path"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
// Provider is one configured Modrinth API client. Every request it performs
// shares the network cache and logger it was constructed with.
type Provider struct {
network *cache.Client
logger *log.Logger
}
// New constructs a Modrinth provider over network and logger. Construction
// performs no network requests.
func New(network *cache.Client, logger *log.Logger) *Provider {
return &Provider{network: network, logger: logger}
}
func (p *Provider) Provider() catalog.Provider {
return catalog.ProviderModrinth
}
func (p *Provider) Search(ctx context.Context, q upstream.Query) (
resp upstream.SearchResponse,
err error,
) {
var facets []facetItems
switch q.FilterEcosystem {
case catalog.EcosystemForge:
facets = append(facets, facetForgeOnly)
case catalog.EcosystemFabric:
facets = append(facets, facetFabricOnly)
case catalog.EcosystemNeoForge:
facets = append(facets, facetNeoforgeOnly)
case catalog.EcosystemBukkit:
facets = append(facets, facetBukkitOnly)
case catalog.EcosystemUnspecified:
fallthrough
default:
facets = append(facets, facetAllLoaders)
}
if !q.IncludeClient {
facets = append(facets, facetServerSupported)
}
internalOptions := searchOptions{
index: "relevance",
facets: facets,
}
searchUrl := searchUrl(q.Keyword, internalOptions)
p.logger.Debug("searching via modrinth api: " + searchUrl)
res, err := p.requestBytes(ctx, searchUrl)
if err != nil {
return resp, fmt.Errorf("modrinth: search request failed: %w", err)
}
if res.StatusCode != 200 {
return resp, fmt.Errorf(
"%w: status %d",
ErrInvalidAPIResponse,
res.StatusCode,
)
}
result := &searchResultResponse{}
err = json.Unmarshal(res.Data, result)
if err != nil {
return resp, err
}
items := make([]upstream.SearchResult, len(result.Hits))
for i, hit := range result.Hits {
items[i] = upstream.SearchResult{
ProjectName: hit.Slug,
Provider: p.Provider(),
Title: hit.Title,
Description: hit.Description,
Downloads: int64(hit.Downloads),
LastUpdated: hit.DateModified,
}
}
resp = upstream.SearchResponse{
Provider: p.Provider(),
Items: items,
Warnings: nil,
}
return
}
func (p *Provider) Fetch(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (upstream.ResolvedPackage, error) {
version, err := p.getVersion(ctx, local, id)
if err != nil {
return upstream.ResolvedPackage{}, err
}
if len(version.Files) == 0 || path.Ext(version.Files[0].Filename) != ".jar" {
return upstream.ResolvedPackage{}, ErrUnsupportedFileType
}
resolved := resolvedPackageFromVersion(*version)
resolved.ID.Ref = id.Ref
resolved.ID.Version = id.Version
return resolved, nil
}
func (p *Provider) Info(
ctx context.Context,
ref catalog.Ref,
) (catalog.Metadata, error) {
project, err := p.getProjectByName(ctx, ref.Name)
if err != nil {
return catalog.Metadata{}, err
}
info := project.ToProjectInformation()
return info, nil
}
var ErrInvalidAPIResponse = errors.New("received non-200 code from modrinth api")
// Modrinth may publish non-JAR files; Lucy accepts only JAR artifacts.
var ErrUnsupportedFileType = errors.New("modrinth: only .jar files are supported")
func (p *Provider) Dependencies(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (*upstream.DependencySet, error) {
version, err := p.getVersion(ctx, local, id)
if err != nil {
return nil, fmt.Errorf("modrinth: dependencies fetch failed: %w", err)
}
dependencies, err := (&modrinthDependencies{
provider: p,
version: version,
platform: id.Ecosystem,
}).ToDependencySet(ctx)
if err != nil {
return nil, err
}
return &dependencies, nil
}
func (p *Provider) ResolveVersionSelector(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (parsed catalog.VersionedRef, err error) {
if id.Ecosystem.IsUnspecified() {
id.Ecosystem = catalog.EcosystemUnspecified
}
parsed.Ecosystem = id.Ecosystem
parsed.Name = id.Name
var v *versionResponse
switch id.Version {
case versionpkg.Stable, versionpkg.Beta, versionpkg.Any:
v, err = p.latestVersion(
ctx,
id.Name,
id.Ecosystem,
local.MinecraftVersion,
id.Version,
)
default:
return id, nil
}
if err != nil {
return id, err
}
parsed.Version = versionpkg.Version(v.VersionNumber)
return parsed, nil
}
package modrinth
import (
"context"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
)
// Builds lists every version published by a Modrinth project. project accepts
// either a slug or a project ID; the project version endpoint resolves both.
// The list is unfiltered: no game version, loader, channel, or status is
// excluded.
func (p *Provider) Builds(
ctx context.Context,
project string,
) ([]upstream.Build, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
versions, err := p.listVersions(ctx, catalog.Name(project))
if err != nil {
return nil, err
}
builds := make([]upstream.Build, len(versions))
for i, v := range versions {
builds[i] = upstream.Build{
Version: v.VersionNumber,
GameVersions: v.GameVersions,
Loaders: v.Loaders,
Channel: string(upstream.ParseReleaseChannel(v.VersionType)),
Published: v.DatePublished,
}
}
return builds, nil
}
package modrinth
import (
"context"
"fmt"
"slices"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
)
func (p *Provider) Candidates(ctx context.Context, project, minecraft string, loader catalog.Ecosystem) ([]upstream.Candidate, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
versions, err := p.listVersions(ctx, catalog.Name(project))
if err != nil {
return nil, err
}
var out []upstream.Candidate
for _, v := range versions {
if v.Status != "" && v.Status != "listed" {
continue
}
if !slices.Contains(v.GameVersions, minecraft) || !versionSupportsLoader(v, loader) {
continue
}
// Modrinth infers the first file as primary when no file carries the
// flag, so a version without a marked primary still must produce a
// primary candidate instead of losing every candidate to the check
// in resolve.
unmarked := !slices.ContainsFunc(v.Files, func(f fileResponse) bool { return f.IsPrimary })
for i, f := range v.Files {
if f.FileType != "" {
continue
}
hashes := map[string]string{}
if f.Hashes.Sha512 != "" {
hashes["sha512"] = f.Hashes.Sha512
}
if f.Hashes.Sha1 != "" {
hashes["sha1"] = f.Hashes.Sha1
}
c := upstream.Candidate{
Provider: catalog.ProviderModrinth,
ProjectID: v.ProjectId,
ReleaseID: v.ID,
FileID: f.ID,
Version: version.Version(v.VersionNumber),
Filename: f.Filename,
URL: f.URL,
Hashes: hashes,
Channel: upstream.ParseReleaseChannel(v.VersionType),
PublishedAt: v.DatePublished,
PrimaryFile: f.IsPrimary || (unmarked && i == 0),
Dependencies: []upstream.ProjectDependency{},
}
for _, d := range v.Dependencies {
projectID := d.ProjectId
if projectID == "" && d.VersionId != "" {
pinned, e := p.getVersionById(ctx, d.VersionId)
if e != nil {
return nil, e
}
projectID = pinned.ProjectId
}
if projectID != "" {
c.Dependencies = append(c.Dependencies, upstream.ProjectDependency{
Provider: catalog.ProviderModrinth,
ProjectID: projectID,
ReleaseID: d.VersionId,
Kind: catalog.ParseDependencyKind(string(d.DependencyType)),
})
}
}
out = append(out, c)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("modrinth project %s has no %s release for Minecraft %s", project, loader, minecraft)
}
return out, nil
}
package modrinth
import (
"time"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
)
// projectResponse
//
// API Example:
// - https://api.modrinth.com/v2/project/P7dR8mSH
// (Fabric API)
// - https://api.modrinth.com/v2/project/1IjD5062
// (Continuity)
type projectResponse struct {
ClientSide string `json:"client_side"`
ServerSide string `json:"server_side"`
GameVersions []string `json:"game_versions"`
ID string `json:"id"`
Slug string `json:"slug"`
ProjectType string `json:"project_type"`
Team string `json:"team"`
Organization string `json:"organization"`
Title string `json:"title"`
Description string `json:"description"`
Body string `json:"body"`
BodyUrl string `json:"body_url"`
PublishedAt time.Time `json:"published"`
Updated time.Time `json:"updated"`
Approved time.Time `json:"approved"`
Queued any `json:"queued"`
Status string `json:"status"`
RequestedStatus any `json:"requested_status"`
ModeratorMessage any `json:"moderator_message"`
License struct {
ID string `json:"id"`
Name string `json:"name"`
URL any `json:"url"`
} `json:"license"`
Downloads int `json:"downloads"`
Followers int `json:"followers"`
Categories []string `json:"categories"`
AdditionalCategories []string `json:"additional_categories"`
Loaders []string `json:"loaders"`
Versions []string `json:"versions"`
IconUrl string `json:"icon_url"`
IssuesUrl string `json:"issues_url"`
SourceUrl string `json:"source_url"`
WikiUrl string `json:"wiki_url"`
DiscordUrl string `json:"discord_url"`
DonationUrls []struct {
ID string `json:"id"`
Platform string `json:"platform"`
URL string `json:"url"`
} `json:"donation_urls"`
Gallery []any `json:"gallery"`
Color int `json:"color"`
ThreadId string `json:"thread_id"`
MonetizationStatus string `json:"monetization_status"`
}
func (p *projectResponse) ToProjectInformation() (info catalog.Metadata) {
info = catalog.Metadata{
Title: p.Title,
Brief: p.Description,
Description: p.Body,
DescriptionIsMarkdown: upstream.LooksLikeMarkdown(p.Body),
License: p.License.Name,
URLs: make([]catalog.URL, 0),
}
if p.DiscordUrl != "" {
info.URLs = append(
info.URLs, catalog.URL{
Name: "Discord",
Type: catalog.URLForum,
URL: p.DiscordUrl,
},
)
}
if p.IssuesUrl != "" {
info.URLs = append(
info.URLs, catalog.URL{
Name: "Issues",
Type: catalog.URLSource,
URL: "",
},
)
}
if p.SourceUrl != "" {
info.URLs = append(
info.URLs, catalog.URL{
Name: "Source",
Type: catalog.URLSource,
URL: p.SourceUrl,
},
)
}
if p.WikiUrl != "" {
info.URLs = append(
info.URLs, catalog.URL{
Name: "Wiki",
Type: catalog.URLWiki,
URL: p.WikiUrl,
},
)
}
for _, donationUrl := range p.DonationUrls {
info.URLs = append(
info.URLs, catalog.URL{
Name: donationUrl.Platform,
Type: catalog.URLSponsor,
URL: donationUrl.URL,
},
)
}
return info
}
// searchResultResponse
//
// Docs
// https://docs.modrinth.com/api/operations/searchprojects/
//
// Example
// https://api.modrinth.com/v2/search?query=carpet&limit=100&index=relevance&facets=%5B%5B%22server_side:required%22,%22server_side:optional%22%5D%5D
type searchResultResponse struct {
Hits []*hit `json:"hits"`
Offset int `json:"offset"`
Limit int `json:"limit"`
TotalHits int `json:"total_hits"`
}
type hit struct {
ProjectId string `json:"project_id"`
ProjectType string `json:"project_type"`
Slug string `json:"slug"`
Author string `json:"author"`
Title string `json:"title"`
Description string `json:"description"`
Categories []string `json:"categories"`
DisplayCategories []string `json:"display_categories"`
Versions []string `json:"versions"`
Downloads int `json:"downloads"`
Follows int `json:"follows"`
IconUrl string `json:"icon_url"`
DateCreated time.Time `json:"date_created"`
DateModified time.Time `json:"date_modified"`
LatestVersion string `json:"latest_version"`
License string `json:"license"`
ClientSide string `json:"client_side"`
ServerSide string `json:"server_side"`
Gallery []string `json:"gallery"`
FeaturedGallery *string `json:"featured_gallery"`
Color *int `json:"color"`
}
// versionResponse
//
// Docs
// https://docs.modrinth.com/api/operations/getversion/
//
// Example
// https://api.modrinth.com/v2/version/F7LVluUL
type versionResponse struct {
GameVersions []string `json:"game_versions"`
Loaders []string `json:"loaders"`
ID string `json:"id"`
ProjectId string `json:"project_id"`
AuthorId string `json:"author_id"`
Featured bool `json:"featured"`
Name string `json:"name"`
VersionNumber string `json:"version_number"`
Changelog string `json:"changelog"`
ChangelogUrl string `json:"changelog_url"`
DatePublished time.Time `json:"date_published"`
Downloads int `json:"downloads"`
VersionType string `json:"version_type"`
Status string `json:"status"`
RequestedStatus any `json:"requested_status"`
Files []fileResponse `json:"files"`
Dependencies []dependenciesResponse `json:"dependencies"`
}
func (v versionResponse) ToPackageRemote() upstream.ResolvedPackage {
remote := upstream.ResolvedPackage{
ID: catalog.VersionedRef{Ref: catalog.Ref{Provider: catalog.ProviderModrinth}},
FileURL: v.Files[0].URL,
Filename: v.Files[0].Filename,
}
if h := v.Files[0].Hashes; h.Sha512 != "" {
remote.Hash = h.Sha512
remote.HashAlgorithm = "sha512"
} else if h.Sha1 != "" {
remote.Hash = h.Sha1
remote.HashAlgorithm = "sha1"
}
return remote
}
func resolvedPackageFromVersion(v versionResponse) upstream.ResolvedPackage {
return v.ToPackageRemote()
}
type dependencyType string
const (
required dependencyType = "required"
optional dependencyType = "optional"
incompatible dependencyType = "incompatible"
embedded dependencyType = "embedded"
)
type fileResponse struct {
ID string `json:"id"`
Hashes struct {
Sha1 string `json:"sha1"`
Sha512 string `json:"sha512"`
} `json:"hashes"`
URL string `json:"url"`
Filename string `json:"filename"`
IsPrimary bool `json:"primary"`
Size int `json:"size"`
FileType string `json:"file_type"`
}
type dependenciesResponse struct {
VersionId string `json:"version_id"`
ProjectId string `json:"project_id"`
FileName string `json:"file_name"`
DependencyType dependencyType `json:"dependency_type"`
}
// memberResponse
//
// Docs
// https://docs.modrinth.com/api/operations/getprojectteammembers/
//
// Example
// https://api.modrinth.com/v2/project/carpet/members
type memberResponse struct {
Role string `json:"role"`
TeamId string `json:"team_id"`
User userResponse `json:"user"`
Permissions any `json:"permissions"`
Accepted bool `json:"accepted"`
PayoutsSplit any `json:"payouts_split"`
Ordering int `json:"ordering"`
}
// userResponse
//
// # The url can either be an id or username
//
// Example
// https://modrinth.com/user/gnembon
type userResponse struct {
ID string `json:"id"`
Username string `json:"username"`
AvatarUrl string `json:"avatar_url"`
Bio string `json:"bio"`
Created time.Time `json:"created"`
Role string `json:"role"`
Badges int `json:"badges"`
AuthProviders string `json:"auth_providers"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
HasPassword bool `json:"has_password"`
HasTotp bool `json:"has_totp"`
PayoutData string `json:"payout_data"`
StripeCustomerId string `json:"stripe_customer_id"`
AllowFriendRequests bool `json:"allow_friend_requests"`
GithubId string `json:"github_id"`
}
package modrinth
import (
"context"
"errors"
"fmt"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
// modrinthDependencies wraps a Modrinth versionResponse for dependency
// normalization.
type modrinthDependencies struct {
provider *Provider
version *versionResponse
platform catalog.Ecosystem
}
func (m *modrinthDependencies) ToDependencySet(
ctx context.Context,
) (upstream.DependencySet, error) {
result := upstream.DependencySet{
FromArtifact: false,
}
for _, dep := range m.version.Dependencies {
if err := ctx.Err(); err != nil {
return upstream.DependencySet{}, err
}
if dep.DependencyType == incompatible {
continue
}
parentId := catalog.VersionedRef{
Name: input.ToProjectName(m.version.ID),
Ecosystem: m.platform,
Version: versionpkg.Version(m.version.VersionNumber),
}
depId, err := m.provider.DependencyToPackage(ctx, parentId, &dep)
if err != nil {
if ctx.Err() != nil {
return upstream.DependencySet{}, ctx.Err()
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return upstream.DependencySet{}, err
}
m.provider.logger.Warn(
fmt.Errorf(
"modrinth: skipping dependency with resolution error: %w",
err,
),
)
continue
}
mandatory := dep.DependencyType == required
result.Dependencies = append(
result.Dependencies, catalog.Dependency{
ID: depId,
Mandatory: mandatory,
},
)
}
if err := ctx.Err(); err != nil {
return upstream.DependencySet{}, err
}
return result, nil
}
package modrinth
import (
"context"
"encoding/hex"
"encoding/json/v2"
"fmt"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
// https://docs.modrinth.com/api/operations/versionfromhash/
const versionFileURLPrefix = "https://api.modrinth.com/v2/version_file/"
// versionFileResponse is the response from GET /v2/version_file/{hash}.
type versionFileResponse struct {
ProjectId string `json:"project_id"`
VersionNumber string `json:"version_number"`
Loaders []string `json:"loaders"`
}
func (p *Provider) PackageByHash(
ctx context.Context,
hashable upstream.ArtifactFingerprint,
) (
ref catalog.VersionedRef,
hash string,
ok bool,
err error,
) {
hashBytes, err := hashable.SHA1()
if err != nil {
return ref, hash, false, err
}
hash = hex.EncodeToString(hashBytes[:])
u := versionFileURLPrefix + hash + "?algorithm=sha1"
p.logger.Debug("modrinth hash lookup: " + u)
res, err := p.requestBytes(ctx, u)
if err != nil {
return ref, hash, false, err
}
if res.StatusCode == 404 {
return ref, hash, false, nil
}
if res.StatusCode != 200 {
return ref, hash, false, fmt.Errorf(
"modrinth: hash lookup returned status %d",
res.StatusCode,
)
}
var version versionFileResponse
err = json.Unmarshal(res.Data, &version)
if err != nil || version.ProjectId == "" {
return ref, hash, false, nil
}
project, err := p.getProjectById(ctx, version.ProjectId)
if err != nil {
return ref, hash, false, err
}
ref = catalog.VersionedRef{
Name: catalog.Name(project.Slug),
Provider: p.Provider(),
Ecosystem: ecosystemFromModrinthLoaders(version.Loaders),
Version: versionpkg.Version(version.VersionNumber),
}
return ref, hash, true, nil
}
func ecosystemFromModrinthLoaders(loaders []string) catalog.Ecosystem {
for _, loader := range loaders {
p := catalog.Ecosystem(loader)
if p.IsModding() || p == catalog.EcosystemBukkit || p == catalog.EcosystemPaper {
return p
}
}
if len(loaders) > 0 {
return catalog.Ecosystem(loaders[0])
}
return catalog.EcosystemUnspecified
}
package modrinth
import (
"github.com/mclucy/lucy/catalog"
versionpkg "github.com/mclucy/lucy/version"
)
func selectExactVersion(
versions []*versionResponse,
id catalog.VersionedRef,
gameVersion versionpkg.Version,
) *versionResponse {
for _, version := range versions {
if versionpkg.Version(version.VersionNumber) == id.Version &&
versionSupportsLoader(version, id.Ecosystem) &&
versionSupportsGameVersion(version, gameVersion) {
return version
}
}
return nil
}
func versionSupportsGameVersion(
version *versionResponse,
gameVersion versionpkg.Version,
) bool {
if !localGameVersionKnown(gameVersion) {
return true
}
for _, supported := range version.GameVersions {
if supported == gameVersion.String() {
return true
}
}
return false
}
func localGameVersionKnown(gameVersion versionpkg.Version) bool {
return gameVersion != "" &&
!gameVersion.IsInvalid() &&
!gameVersion.CanInfer()
}
// selectLatestVersionForSelector applies issue 60 selector semantics. stable
// accepts release versions only; beta accepts any channel; any prefers a
// release but still falls back to a prerelease.
func selectLatestVersionForSelector(
versions []*versionResponse,
platform catalog.Ecosystem,
gameVersion versionpkg.Version,
filterByLoader bool,
selector versionpkg.Version,
) *versionResponse {
switch selector {
case versionpkg.Stable:
return latestReleaseVersion(
versions,
platform,
gameVersion,
filterByLoader,
)
case versionpkg.Beta:
return latestAnyVersion(versions, platform, gameVersion, filterByLoader)
default:
if selected := latestReleaseVersion(
versions,
platform,
gameVersion,
filterByLoader,
); selected != nil {
return selected
}
return latestAnyVersion(versions, platform, gameVersion, filterByLoader)
}
}
func latestReleaseVersion(
versions []*versionResponse,
platform catalog.Ecosystem,
gameVersion versionpkg.Version,
filterByLoader bool,
) *versionResponse {
var selected *versionResponse
for _, version := range versions {
if (filterByLoader && !versionSupportsLoader(version, platform)) ||
!versionSupportsGameVersion(version, gameVersion) {
continue
}
if version.VersionType == "release" &&
(selected == nil || version.DatePublished.After(selected.DatePublished)) {
selected = version
}
}
return selected
}
func latestAnyVersion(
versions []*versionResponse,
platform catalog.Ecosystem,
gameVersion versionpkg.Version,
filterByLoader bool,
) *versionResponse {
var selected *versionResponse
for _, version := range versions {
if (filterByLoader && !versionSupportsLoader(version, platform)) ||
!versionSupportsGameVersion(version, gameVersion) {
continue
}
if selected == nil || version.DatePublished.After(selected.DatePublished) {
selected = version
}
}
return selected
}
package modrinth
import (
"context"
"errors"
"fmt"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
versionpkg "github.com/mclucy/lucy/version"
)
func (p *Provider) getProjectId(
ctx context.Context,
slug catalog.Name,
) (id string, err error) {
modrinthProject := projectResponse{}
if err := p.requestJSON(
ctx,
projectUrl(string(slug)),
&modrinthProject,
ErrNoProject,
); err != nil {
return "", err
}
id = modrinthProject.ID
return
}
func (p *Provider) getProjectById(
ctx context.Context,
id string,
) (project *projectResponse, err error) {
project = &projectResponse{}
if err := p.requestJSON(
ctx,
projectUrl(id),
project,
ErrNoProject,
); err != nil {
return nil, err
}
return
}
func (p *Provider) getProjectByName(
ctx context.Context,
slug catalog.Name,
) (project *projectResponse, err error) {
project = &projectResponse{}
if err := p.requestJSON(
ctx,
projectUrl(string(slug)),
project,
ErrNoProject,
); err != nil {
return nil, err
}
return project, nil
}
func (p *Provider) getProjectMembers(
ctx context.Context,
id string,
) (members []*memberResponse, err error) {
if err := p.requestJSON(
ctx,
projectMemberUrl(id),
&members,
ErrNoMember,
); err != nil {
return nil, err
}
return members, nil
}
var ErrInvalidDependency = errors.New("modrinth: invalid dependency")
func (p *Provider) DependencyToPackage(
ctx context.Context,
dependent catalog.VersionedRef,
dependency *dependenciesResponse,
) (
pkg catalog.VersionedRef,
err error,
) {
var version *versionResponse
var project *projectResponse
// Modrinth dependencies do not cross platforms: a dependency's platform
// is taken from the dependent package, not from the dependency itself.
pkg.Ecosystem = dependent.Ecosystem
if dependency.VersionId != "" && dependency.ProjectId != "" {
version, err = p.getVersionById(ctx, dependency.VersionId)
if err != nil {
return pkg, fmt.Errorf("resolve dependency version: %w", err)
}
project, err = p.getProjectById(ctx, dependency.ProjectId)
if err != nil {
return pkg, fmt.Errorf("resolve dependency project: %w", err)
}
} else if dependency.VersionId != "" {
version, err = p.getVersionById(ctx, dependency.VersionId)
if err != nil {
return pkg, fmt.Errorf("resolve dependency version: %w", err)
}
project, err = p.getProjectById(ctx, version.ProjectId)
if err != nil {
return pkg, fmt.Errorf("resolve dependency project: %w", err)
}
} else if dependency.ProjectId != "" {
project, err = p.getProjectById(ctx, dependency.ProjectId)
if err != nil {
return pkg, fmt.Errorf("resolve dependency project: %w", err)
}
_, err = p.latestVersion(
ctx,
input.ToProjectName(project.Slug),
dependent.Ecosystem,
versionpkg.Unknown,
versionpkg.Any,
)
if err != nil {
return pkg, fmt.Errorf("resolve dependency latest version: %w", err)
}
pkg.Name = input.ToProjectName(project.Slug)
pkg.Version = versionpkg.Any
return pkg, nil
} else {
return pkg, ErrInvalidDependency
}
pkg.Name = input.ToProjectName(project.Slug)
pkg.Version = versionpkg.Version(version.VersionNumber)
return pkg, nil
}
package modrinth
import (
"context"
"encoding/json/v2"
"fmt"
"net/http"
"github.com/mclucy/lucy/cache"
)
func (p *Provider) requestBytes(
ctx context.Context,
rawURL string,
) (*cache.BytesResponse, error) {
p.logger.Debug("modrinth api: GET " + rawURL)
return p.network.CachedGetRequest(
ctx,
rawURL,
cache.BytesRequestOptions{Kind: cache.KindMetadata},
)
}
func (p *Provider) requestJSON(
ctx context.Context,
rawURL string,
out any,
notFound error,
) error {
res, err := p.requestBytes(ctx, rawURL)
if err != nil {
return fmt.Errorf("modrinth: request failed: %w", err)
}
if res.StatusCode == http.StatusNotFound && notFound != nil {
return notFound
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf(
"%w: status %d",
ErrInvalidAPIResponse,
res.StatusCode,
)
}
if err := json.Unmarshal(res.Data, out); err != nil {
if notFound != nil {
return fmt.Errorf("%w: %w", notFound, err)
}
return fmt.Errorf("modrinth: failed to parse response: %w", err)
}
return nil
}
package modrinth
import (
"strings"
)
type searchOptions struct {
index string
facets []facetItems
}
type facetItemOperation uint8
const (
operationEq facetItemOperation = iota
operationNeq
operationLeq
operationGeq
operationLt
operationGt
)
func (op facetItemOperation) String() string {
switch op {
case operationEq:
return ":"
case operationNeq:
return "!="
case operationLeq:
return "<="
case operationGeq:
return ">="
case operationLt:
return "<"
case operationGt:
return ">"
default:
return ""
}
}
// facet is the data structure to construct an advanced Modrinth search. It
// does not contain all the search options, only the ones that are expected in this
// program.
//
// From Modrinth docs:
//
// In order to then use these facets, you need a value to filter by, as well as
// an operation to perform on this value. The most common operation is ':'
// (same as =), though you can also use !=, >=, >, <=, and <. Join together the
// type, operation, and value, and you’ve got your string.
//
// {type} {operation} {value}
//
// categories = adventure
// versions != 1.20.1
// downloads <= 100
//
// You then join these strings together in arrays to signal AND OR operators.
//
// OR
// All elements in a single array are considered to be joined by OR statements.
// For example, the search [["versions:1.16.5", "versions:1.17.1"]] translates
// to Projects that support 1.16.5 OR 1.17.1.
//
// AND
// Separate arrays are considered to be joined by AND statements. For example,
// the search [["versions:1.16.5"], ["project_type:modpack"]] translates to
// Projects that support 1.16.5 AND are modpacks.
//
// API Docs: https://docs.modrinth.com/api/operations/searchprojects/
type facetItem struct {
Type string
Operation facetItemOperation
Match string
}
func (f *facetItem) String() string {
return `"` + f.Type + f.Operation.String() + f.Match + `"`
}
// facetItems is an array of facetItem. It represents an expression joined by
// OR statements. A complete facet is an array of facetItems, with each array
// joined by AND statements.
type facetItems []facetItem
// serializeFacet renders expressions as Modrinth's nested JSON arrays; see
// facetItems for the OR/AND semantics.
func serializeFacet(expressions ...facetItems) string {
var sb strings.Builder
sb.WriteRune('[')
for i, expression := range expressions {
if i > 0 {
sb.WriteRune(',')
}
sb.WriteRune('[')
for j, item := range expression {
if j > 0 {
sb.WriteRune(',')
}
sb.WriteString(item.String())
}
sb.WriteRune(']')
}
sb.WriteRune(']')
return sb.String()
}
var facetAllLoaders = facetItems{
{
Type: "categories",
Operation: operationEq,
Match: "forge",
},
{
Type: "categories",
Operation: operationEq,
Match: "fabric",
},
{
Type: "categories",
Operation: operationEq,
Match: "quilt",
},
{
Type: "categories",
Operation: operationEq,
Match: "liteloader",
},
{
Type: "categories",
Operation: operationEq,
Match: "modloader",
},
{
Type: "categories",
Operation: operationEq,
Match: "rift",
},
{
Type: "categories",
Operation: operationEq,
Match: "neoforge",
},
{
Type: "categories",
Operation: operationEq,
Match: "bukkit",
},
}
var facetForgeOnly = facetItems{
{
Type: "categories",
Operation: operationEq,
Match: "forge",
},
}
var facetFabricOnly = facetItems{
{
Type: "categories",
Operation: operationEq,
Match: "fabric",
},
}
var facetNeoforgeOnly = facetItems{
{
Type: "categories",
Operation: operationEq,
Match: "neoforge",
},
}
var facetBukkitOnly = facetItems{
{
Type: "categories",
Operation: operationEq,
Match: "bukkit",
},
}
var facetServerSupported = facetItems{
{
Type: "server_side",
Operation: operationEq,
Match: "required",
},
{
Type: "server_side",
Operation: operationEq,
Match: "optional",
},
}
var facetClientSupported = facetItems{
{
Type: "client_side",
Operation: operationEq,
Match: "required",
},
{
Type: "client_side",
Operation: operationEq,
Match: "optional",
},
}
var facetBothRequired = []facetItems{
{
{
Type: "server_side",
Operation: operationEq,
Match: "required",
},
},
{
{
Type: "client_side",
Operation: operationEq,
Match: "required",
},
},
}
var facetBothSupported = []facetItems{
{
{
Type: "server_side",
Operation: operationEq,
Match: "required",
},
{
Type: "server_side",
Operation: operationEq,
Match: "optional",
},
},
{
{
Type: "client_side",
Operation: operationEq,
Match: "required",
},
{
Type: "client_side",
Operation: operationEq,
Match: "optional",
},
},
}
package modrinth
import (
"net/url"
"strings"
"text/template"
"github.com/mclucy/lucy/catalog"
)
const projectUrlPrefix = "https://api.modrinth.com/v2/project/"
func versionsUrl(slug catalog.Name) (urlString string) {
urlString, _ = url.JoinPath(
projectUrlPrefix,
string(slug),
"version",
)
return
}
const versionUrlPrefix = `https://api.modrinth.com/v2/version/`
func versionUrl(id string) (urlString string) {
return versionUrlPrefix + url.PathEscape(id)
}
// projectUrl returns the URL for a project with the given SourceModrinth project id
// or slug (package name).
func projectUrl(suffix string) (urlString string) {
return projectUrlPrefix + url.PathEscape(suffix)
}
func projectMemberUrl(suffix string) (urlString string) {
return projectUrl(suffix) + "/members"
}
func projectDependencyUrl(suffix string) (urlString string) {
return projectUrl(suffix) + "/dependencies"
}
const searchUrlTemplate = `https://api.modrinth.com/v2/search?query={{.query}}&limit=100&index={{.index}}&facets={{.facets}}`
func searchUrl(
query string,
option searchOptions,
) (urlString string) {
urlTemplate, _ := template.New("modrinth_search_url").Parse(searchUrlTemplate)
urlBuilder := strings.Builder{}
// The template is a static constant; execution only fails on a writer
// error, which strings.Builder cannot produce.
_ = urlTemplate.Execute(
&urlBuilder,
map[string]any{
"query": url.QueryEscape(query),
"index": option.index,
"facets": url.QueryEscape(serializeFacet(option.facets...)),
},
)
urlString = urlBuilder.String()
return urlString
}
const userHomepageUrlPrefix = `https://modrinth.com/user/`
// userHomepageUrl's suffix is the user's username or id.
func userHomepageUrl(suffix string) (urlString string) {
return userHomepageUrlPrefix + suffix
}
package modrinth
import (
"context"
"errors"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
var (
ErrNoVersion = errors.New("modrinth version not found")
ErrNoProject = errors.New("modrinth project not found")
ErrNoMember = errors.New("modrinth project member not found")
)
// Known crash risk: this path has caused segmentation faults.
func (p *Provider) listVersions(
ctx context.Context,
slug catalog.Name,
) (versions []*versionResponse, err error) {
var out []*versionResponse
if err := p.requestJSON(
ctx,
versionsUrl(slug),
&out,
ErrNoProject,
); err != nil {
return nil, err
}
return out, nil
}
// getVersion is named so because a Lucy package corresponds to a Modrinth
// version; see the package terminology note.
func (p *Provider) getVersion(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (v *versionResponse, err error) {
versions, err := p.listVersions(ctx, id.Name)
if err != nil {
return nil, err
}
if id.Version.CanInfer() {
v, err = p.latestVersion(
ctx,
id.Name,
id.Ecosystem,
local.MinecraftVersion,
id.Version,
)
if err != nil {
return nil, err
}
return v, nil
}
if selected := selectExactVersion(versions, id, local.MinecraftVersion); selected != nil {
return selected, nil
}
return nil, ErrNoVersion
}
func (p *Provider) getVersionById(
ctx context.Context,
id string,
) (v *versionResponse, err error) {
v = &versionResponse{}
if err := p.requestJSON(ctx, versionUrl(id), v, ErrNoVersion); err != nil {
return nil, err
}
return
}
func versionSupportsLoader(
version *versionResponse,
loader catalog.Ecosystem,
) bool {
for _, l := range version.Loaders {
if catalog.Ecosystem(l).Satisfy(loader) {
return true
}
}
return false
}
// latestVersion selects the newest version matching the selector. stable
// accepts releases only; beta accepts any channel; any prefers a release and
// falls back to a prerelease.
func (p *Provider) latestVersion(
ctx context.Context,
slug catalog.Name,
platform catalog.Ecosystem,
gameVersion versionpkg.Version,
selector versionpkg.Version,
) (v *versionResponse, err error) {
versions, err := p.listVersions(ctx, slug)
if err != nil {
return nil, err
}
filterByLoader := platform != catalog.EcosystemUnspecified
v = selectLatestVersionForSelector(
versions,
platform,
gameVersion,
filterByLoader,
selector,
)
if v == nil {
return nil, ErrNoVersion
}
p.logger.Debug("latest version of " + slug.String() + ": " + v.VersionNumber)
return v, nil
}
package mojang
import (
"context"
"encoding/json/v2"
"errors"
"fmt"
"strings"
"time"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/internal/fileschema"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
)
const VersionManifestURL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
// Provider resolves Mojang's version manifest and per-version metadata over
// the shared network cache.
type Provider struct {
network *cache.Client
}
// New constructs a Mojang installer provider. network supplies the cached HTTP
// transport; construction performs no requests.
func New(network *cache.Client) *Provider {
return &Provider{network: network}
}
func (p *Provider) Provider() catalog.Provider {
return catalog.ProviderMojang
}
type versionDetail struct {
JavaVersion *struct {
MajorVersion int `json:"majorVersion"`
} `json:"javaVersion"`
Downloads struct {
Server *struct {
Sha1 string `json:"sha1"`
URL string `json:"url"`
} `json:"server"`
} `json:"downloads"`
}
func (p *Provider) VersionManifest(ctx context.Context) (
*fileschema.ApiMojangMinecraftVersionManifest,
error,
) {
data, err := p.network.CachedGetBytes(
ctx,
VersionManifestURL,
cache.BytesRequestOptions{Kind: cache.KindMetadata},
)
if err != nil {
return nil, fmt.Errorf("fetch mojang version manifest failed: %w", err)
}
manifest := &fileschema.ApiMojangMinecraftVersionManifest{}
if err := json.Unmarshal(data, manifest); err != nil {
return nil, fmt.Errorf("parse mojang version manifest failed: %w", err)
}
if len(manifest.Versions) == 0 {
return nil, errors.New("mojang version manifest has no versions")
}
return manifest, nil
}
func ResolveVersionEntry(
manifest *fileschema.ApiMojangMinecraftVersionManifest,
targetVersion version.Version,
) (string, string, error) {
selected := targetVersion.String()
if targetVersion == "" || targetVersion.CanInfer() || targetVersion == version.Unknown {
selected = manifest.Latest.Release
}
if strings.EqualFold(selected, "snapshot") {
selected = manifest.Latest.Snapshot
}
for i := range manifest.Versions {
if manifest.Versions[i].ID == selected {
return manifest.Versions[i].ID, manifest.Versions[i].URL, nil
}
}
return "", "", fmt.Errorf(
"minecraft version %s not found in mojang manifest",
targetVersion.String(),
)
}
func (p *Provider) ResolveVersionSelector(
ctx context.Context,
_ upstream.LocalContext,
id catalog.VersionedRef,
) (catalog.VersionedRef, error) {
manifest, err := p.VersionManifest(ctx)
if err != nil {
return id, err
}
versionID, _, err := ResolveVersionEntry(manifest, id.Version)
if err != nil {
return id, err
}
return catalog.VersionedRef{
Ecosystem: catalog.EcosystemMinecraft,
Name: "minecraft",
Version: version.Version(versionID),
}, nil
}
func (p *Provider) Fetch(
ctx context.Context,
_ upstream.LocalContext,
id catalog.VersionedRef,
) (upstream.ResolvedPackage, error) {
manifest, err := p.VersionManifest(ctx)
if err != nil {
return upstream.ResolvedPackage{}, err
}
versionID, versionURL, err := ResolveVersionEntry(manifest, id.Version)
if err != nil {
return upstream.ResolvedPackage{}, err
}
detail, err := p.fetchVersionDetail(ctx, versionURL)
if err != nil {
return upstream.ResolvedPackage{}, err
}
if detail.Downloads.Server == nil {
return upstream.ResolvedPackage{}, fmt.Errorf(
"minecraft version %s does not provide a dedicated server jar",
versionID,
)
}
return upstream.ResolvedPackage{
FileURL: detail.Downloads.Server.URL,
Filename: "server.jar",
Hash: detail.Downloads.Server.Sha1,
HashAlgorithm: "sha1",
}, nil
}
// legacyJavaAdvisory is the Java major version for versions whose metadata
// predates the javaVersion block.
const legacyJavaAdvisory = 8
// JavaAdvisory returns the Java major version Mojang's launcher ships for the
// Minecraft version. Lucy treats it as an advisory minimum.
//
// Versions from 13w24a through 1.6.4 carry no javaVersion block. That metadata
// field arrived later, and Mojang pairs the versions on both sides of the gap
// with JRE 8, so those versions fall back to 8.
func (p *Provider) JavaAdvisory(
ctx context.Context,
minecraft version.Version,
) (int, error) {
manifest, err := p.VersionManifest(ctx)
if err != nil {
return 0, err
}
versionID, versionURL, err := ResolveVersionEntry(manifest, minecraft)
if err != nil {
return 0, err
}
detail, err := p.fetchVersionDetail(ctx, versionURL)
if err != nil {
return 0, err
}
if detail.JavaVersion == nil {
return legacyJavaAdvisory, nil
}
if detail.JavaVersion.MajorVersion <= 0 {
return 0, fmt.Errorf(
"minecraft version %s declares no java major version",
versionID,
)
}
return detail.JavaVersion.MajorVersion, nil
}
func (p *Provider) fetchVersionDetail(
ctx context.Context,
versionURL string,
) (*versionDetail, error) {
data, err := p.network.CachedGetBytes(
ctx,
versionURL,
cache.BytesRequestOptions{
Kind: cache.KindMetadata,
TTL: 7 * 24 * time.Hour,
},
)
if err != nil {
return nil, fmt.Errorf(
"fetch minecraft version metadata failed: %w",
err,
)
}
detail := &versionDetail{}
if err := json.Unmarshal(data, detail); err != nil {
return nil, fmt.Errorf(
"parse minecraft version metadata failed: %w",
err,
)
}
return detail, nil
}
package neoforge
import (
"context"
"encoding/xml"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
var (
mavenBaseURL = "https://maven.neoforged.net/releases/net/neoforged/neoforge"
metadataURL = "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml"
docsURL = "https://docs.neoforged.net/user/docs/server/"
)
// Provider resolves NeoForge Maven metadata and installer artifacts over the
// shared network cache.
type Provider struct {
network *cache.Client
}
// New constructs a NeoForge installer provider. network supplies the cached
// HTTP transport; construction performs no requests.
func New(network *cache.Client) *Provider {
return &Provider{network: network}
}
func (p *Provider) Provider() catalog.Provider {
return catalog.ProviderNeoForge
}
type mavenMetadata struct {
Versioning struct {
Latest string `xml:"latest"`
Release string `xml:"release"`
Versions []string `xml:"versions>version"`
} `xml:"versioning"`
}
// LatestMinecraftVersion reports the newest Minecraft version the metadata
// lists a build for. NeoForge encodes the Minecraft version in the loader
// version prefix.
func (p *Provider) LatestMinecraftVersion(ctx context.Context) (
versionpkg.Version,
error,
) {
body, err := p.network.CachedGetBytes(
ctx,
metadataURL,
cache.BytesRequestOptions{Kind: cache.KindMetadata},
)
if err != nil {
return "", fmt.Errorf("fetch NeoForge metadata failed: %w", err)
}
var meta mavenMetadata
if err := xml.Unmarshal(body, &meta); err != nil {
return "", fmt.Errorf("parse NeoForge metadata failed: %w", err)
}
versions := make([]versionpkg.Version, 0, len(meta.Versioning.Versions))
for _, loader := range meta.Versioning.Versions {
if minecraft, ok := minecraftVersionOf(loader); ok {
versions = append(versions, minecraft)
}
}
return versionpkg.NewestMinecraft(versions)
}
// neoForgePrefix names the loader version prefix for a Minecraft version. A
// 1.x release drops the leading 1 (1.21.4 → 21.4.), while a year-based
// release keeps its full version (26.3 → 26.3.).
func neoForgePrefix(gameVersion versionpkg.Version) (string, bool) {
parts := strings.Split(gameVersion.String(), ".")
if len(parts) < 2 {
return "", false
}
year, err := strconv.Atoi(parts[0])
if err != nil {
return "", false
}
if year >= 26 {
return gameVersion.String() + ".", true
}
return strings.Join(parts[1:], ".") + ".", true
}
// minecraftVersionOf inverts the loader version prefix: 21.4.16 belongs to
// Minecraft 1.21.4, 21.1.66 to 1.21.1, and 26.3.0.98 to 26.3.
func minecraftVersionOf(loader string) (versionpkg.Version, bool) {
base, _, _ := strings.Cut(loader, "-")
parts := strings.Split(base, ".")
if len(parts) < 3 {
return "", false
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return "", false
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return "", false
}
if major >= 26 {
patch, err := strconv.Atoi(parts[2])
if err != nil {
return "", false
}
if patch != 0 {
return versionpkg.Version(fmt.Sprintf("%d.%d.%d", major, minor, patch)), true
}
return versionpkg.Version(fmt.Sprintf("%d.%d", major, minor)), true
}
if minor == 0 {
return versionpkg.Version(fmt.Sprintf("1.%d", major)), true
}
return versionpkg.Version(fmt.Sprintf("1.%d.%d", major, minor)), true
}
func (p *Provider) ResolveVersionSelector(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (catalog.VersionedRef, error) {
gameVersion, err := minecraftVersionForInstall(local)
if err != nil {
return id, err
}
neoForgeVersion, err := p.neoForgeVersionFromPackageRef(ctx, id, gameVersion)
if err != nil {
return id, err
}
return catalog.VersionedRef{
Ecosystem: catalog.EcosystemNeoForge,
Name: "neoforge",
Version: versionpkg.Version(neoForgeVersion),
}, nil
}
func (p *Provider) Fetch(
ctx context.Context,
local upstream.LocalContext,
id catalog.VersionedRef,
) (upstream.ResolvedPackage, error) {
gameVersion, err := minecraftVersionForInstall(local)
if err != nil {
return upstream.ResolvedPackage{}, err
}
neoForgeVersion, err := p.neoForgeVersionFromPackageRef(ctx, id, gameVersion)
if err != nil {
return upstream.ResolvedPackage{}, err
}
fileURL := installerURL(neoForgeVersion)
return upstream.ResolvedPackage{
FileURL: fileURL,
Filename: fmt.Sprintf(
"neoforge-%s-installer.jar",
neoForgeVersion,
),
}, nil
}
func minecraftVersionForInstall(local upstream.LocalContext) (versionpkg.Version, error) {
if !local.HasMinecraftVersion() {
return "", errors.New(
"neoforge bootstrap requires a concrete Minecraft version",
)
}
return local.MinecraftVersion, nil
}
func (p *Provider) neoForgeVersionFromPackageRef(
ctx context.Context,
ref catalog.VersionedRef,
gameVersion versionpkg.Version,
) (string, error) {
if ref.Version != versionpkg.Beta &&
ref.Version != versionpkg.Stable &&
ref.Version != versionpkg.Any &&
ref.Version != versionpkg.Unknown {
return ref.Version.String(), nil
}
return p.fetchVersion(ctx, gameVersion, ref.Version)
}
// fetchVersion selects the newest NeoForge version for the Minecraft
// version. stable excludes prerelease builds; beta accepts any build; any
// prefers a release and falls back to a prerelease.
func (p *Provider) fetchVersion(
ctx context.Context,
gameVersion versionpkg.Version,
selector versionpkg.Version,
) (string, error) {
body, err := p.network.CachedGetBytes(
ctx,
metadataURL,
cache.BytesRequestOptions{Kind: cache.KindMetadata},
)
if err != nil {
return "", fmt.Errorf("fetch NeoForge metadata failed: %w", err)
}
var meta mavenMetadata
if err := xml.Unmarshal(body, &meta); err != nil {
return "", fmt.Errorf("parse NeoForge metadata failed: %w", err)
}
neoPrefix, ok := neoForgePrefix(gameVersion)
if !ok {
return "", fmt.Errorf(
"cannot derive NeoForge version prefix from Minecraft version %s",
gameVersion,
)
}
matching := make([]string, 0, len(meta.Versioning.Versions))
for _, v := range meta.Versioning.Versions {
if strings.HasPrefix(v, neoPrefix) {
matching = append(matching, v)
}
}
if len(matching) == 0 {
return "", fmt.Errorf(
"no NeoForge version found for Minecraft %s (looked for prefix %s); see %s",
gameVersion,
neoPrefix,
docsURL,
)
}
switch selector {
case versionpkg.Stable:
for _, v := range slices.Backward(matching) {
if !isPrereleaseVersion(v) {
return v, nil
}
}
return "", fmt.Errorf(
"no stable NeoForge release found for Minecraft %s; see %s",
gameVersion,
docsURL,
)
case versionpkg.Beta:
return matching[len(matching)-1], nil
default:
for _, v := range slices.Backward(matching) {
if !isPrereleaseVersion(v) {
return v, nil
}
}
return matching[len(matching)-1], nil
}
}
func isPrereleaseVersion(version string) bool {
lower := strings.ToLower(version)
for _, marker := range []string{
"-beta", "-alpha", "-pre", "-rc", "-snapshot",
} {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
func installerURL(neoForgeVersion string) string {
return fmt.Sprintf(
"%s/%s/neoforge-%s-installer.jar",
mavenBaseURL,
neoForgeVersion,
neoForgeVersion,
)
}
package spiget
import (
"context"
"errors"
"fmt"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
)
// Provider resolves Spiget resources and versions through one configured
// network cache. logger may be nil, which records nothing.
type Provider struct {
network *cache.Client
logger *log.Logger
}
// New constructs a Spiget provider. network supplies the cached HTTP
// transport; construction performs no requests.
func New(network *cache.Client, logger *log.Logger) *Provider {
return &Provider{network: network, logger: logger}
}
func (p *Provider) Provider() catalog.Provider {
return catalog.ProviderSpiget
}
// Builds lists every published resource version. Spiget exposes no per-version
// game versions, loaders, or channel, so those stay unknown.
func (p *Provider) Builds(ctx context.Context, project string) ([]upstream.Build, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
resource, err := p.resolveResourceByProjectName(ctx, catalog.Name(project))
if err != nil {
return nil, err
}
versions, err := p.listVersions(ctx, resource.ID)
if err != nil {
return nil, err
}
builds := make([]upstream.Build, 0, len(versions))
for _, v := range versions {
builds = append(builds, v.ToBuild())
}
return builds, nil
}
func (p *Provider) Search(ctx context.Context, q upstream.Query) (upstream.SearchResponse, error) {
// Spiget publishes server resources only; IncludeClient needs no filter.
if q.FilterEcosystem == catalog.EcosystemBukkit || q.FilterEcosystem == catalog.EcosystemPaper {
p.logger.Debug("spiget: platform filter is not supported upstream; search will run without a platform query parameter")
}
resp, err := p.searchResources(ctx, q.Keyword)
if err != nil {
return upstream.SearchResponse{}, err
}
return resp.ToSearchResults(p.Provider()), nil
}
func (p *Provider) Fetch(
ctx context.Context,
_ upstream.LocalContext,
id catalog.VersionedRef,
) (upstream.ResolvedPackage, error) {
resource, err := p.resolveResourceByProjectName(ctx, id.Name)
if err != nil {
return upstream.ResolvedPackage{}, err
}
resolved, err := p.resolveVersion(ctx, resource, id.Version)
if err != nil {
return upstream.ResolvedPackage{}, err
}
r := resolved.ToPackageRemote()
return upstream.ResolvedPackage{
FileURL: r.FileURL,
Filename: r.Filename,
Hash: r.Hash,
HashAlgorithm: r.HashAlgorithm,
}, nil
}
func (p *Provider) Info(ctx context.Context, ref catalog.Ref) (catalog.Metadata, error) {
resource, err := p.resolveResourceByProjectName(ctx, ref.Name)
if err != nil {
return catalog.Metadata{}, err
}
info := resource.ToProjectInformation()
return info, nil
}
func (p *Provider) Dependencies(
_ context.Context,
_ upstream.LocalContext,
_ catalog.VersionedRef,
) (*upstream.DependencySet, error) {
return &upstream.DependencySet{FromArtifact: false}, nil
}
func (p *Provider) ResolveVersionSelector(
ctx context.Context,
_ upstream.LocalContext,
id catalog.VersionedRef,
) (parsed catalog.VersionedRef, err error) {
parsed = id
switch id.Version {
case "", version.Any, version.Beta, version.Stable:
default:
return id, nil
}
resource, err := p.resolveResourceByProjectName(ctx, id.Name)
if err != nil {
return id, err
}
resolved, err := p.resolveVersion(ctx, resource, id.Version)
if err != nil {
return id, err
}
parsed.Version = resolved.LucyVersion()
p.logger.Debug("parsed from " + id.StringQualified() + " to " + parsed.StringQualified())
return parsed, nil
}
var (
ErrNoProject = errors.New("spiget: project not found")
ErrNoVersion = errors.New("spiget: version not found")
)
func unexpectedStatusError(url string, statusCode int) error {
return fmt.Errorf("spiget: unexpected status %d for %s", statusCode, url)
}
package spiget
import (
"encoding/base64"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"unicode"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/upstream"
versionpkg "github.com/mclucy/lucy/version"
)
const (
spigetAPIBaseURL = "https://api.spiget.org/v2"
spigotWebsiteBaseURL = "https://www.spigotmc.org/"
)
type decodedHTML struct {
Text string
Valid bool
}
// resolvedVersion keeps enough provider-local identity to resolve exact Spiget
// downloads while still exposing Lucy-friendly human version names.
type resolvedVersion struct {
ResourceID int64
VersionID int64
VersionName string
ProjectName string
FileType string
External bool
ExternalURL string
UUID string
}
func (s searchResponse) ToSearchResults(source catalog.Provider) upstream.SearchResponse {
results := upstream.SearchResponse{
Provider: source,
Items: make([]upstream.SearchResult, 0, len(s)),
}
for _, resource := range s {
if resource.Name == "" {
continue
}
results.Items = append(
results.Items, upstream.SearchResult{
ProjectName: normalizedProjectName(resource.Name).String(),
Provider: source,
},
)
}
return results
}
func (r resourceResponse) ToProjectInformation() catalog.Metadata {
description := combineHTMLSections(
decodeBase64HTML(r.Description),
decodeBase64HTML(r.Documentation),
)
info := catalog.Metadata{
Title: r.Name,
Brief: r.Tag,
Description: description,
DescriptionIsMarkdown: false,
URLs: make([]catalog.URL, 0, len(r.Links)+2),
}
appendLinkURLs(&info, r.Links)
if r.SourceCodeLink != "" {
info.URLs = append(
info.URLs,
catalog.URL{
Name: "Source", Type: catalog.URLSource, URL: r.SourceCodeLink,
},
)
}
if r.DonationLink != "" {
info.URLs = append(
info.URLs,
catalog.URL{
Name: "Donate", Type: catalog.URLSponsor, URL: r.DonationLink,
},
)
}
return info
}
// NewResolvedVersion preserves both Lucy-facing human versions and Spiget's
// numeric resource/version identifiers for later exact download resolution.
func NewResolvedVersion(
resource resourceResponse,
version versionResponse,
) resolvedVersion {
return resolvedVersion{
ResourceID: resource.ID,
VersionID: version.ID,
VersionName: version.Name,
ProjectName: normalizedProjectName(resource.Name).String(),
FileType: resource.File.Type,
External: resource.External,
ExternalURL: resource.File.ExternalURL,
UUID: version.UUID,
}
}
// ToBuild maps one Spiget resource version to a build. Spiget version records
// carry no game versions, loaders, or channel; resource-level tested versions
// are not build-specific and stay out of the build.
func (v versionResponse) ToBuild() upstream.Build {
build := upstream.Build{Version: v.Name}
if build.Version == "" && v.ID != 0 {
build.Version = strconv.FormatInt(v.ID, 10)
}
if v.ReleaseDate > 0 {
build.Published = time.Unix(v.ReleaseDate, 0).UTC()
}
return build
}
func (r resolvedVersion) LucyVersion() versionpkg.Version {
if r.VersionName != "" {
return versionpkg.Version(r.VersionName)
}
if r.VersionID != 0 {
return versionpkg.Version(strconv.FormatInt(r.VersionID, 10))
}
return versionpkg.Unknown
}
func (r resolvedVersion) Matches(version versionpkg.Version) bool {
requested := strings.TrimSpace(version.String())
if requested == "" || requested == versionpkg.Any.String() {
return false
}
if requested == string(r.LucyVersion()) {
return true
}
return r.VersionID != 0 && requested == strconv.FormatInt(r.VersionID, 10)
}
func (r resolvedVersion) ToPackageRemote() upstream.ResolvedPackage {
return upstream.ResolvedPackage{
ID: catalog.VersionedRef{Ref: catalog.Ref{Provider: catalog.ProviderSpiget}},
FileURL: r.downloadURL(),
Filename: r.filename(),
}
}
func (r resolvedVersion) downloadURL() string {
if r.External && r.ExternalURL != "" {
return r.ExternalURL
}
if r.ResourceID == 0 || r.VersionID == 0 {
return ""
}
return fmt.Sprintf(
"%s/resources/%d/versions/%d/download",
spigetAPIBaseURL,
r.ResourceID,
r.VersionID,
)
}
func (r resolvedVersion) filename() string {
base := strings.TrimSpace(r.ProjectName)
if base == "" {
base = strconv.FormatInt(r.ResourceID, 10)
}
version := strings.TrimSpace(r.VersionName)
if version == "" && r.VersionID != 0 {
version = strconv.FormatInt(r.VersionID, 10)
}
if version != "" {
base += "-" + version
}
if ext := normalizedFileExtension(r.FileType); ext != "" {
return base + ext
}
return base
}
func normalizedFileExtension(fileType string) string {
trimmed := strings.TrimSpace(fileType)
if trimmed == "" || strings.EqualFold(trimmed, "external") {
return ""
}
if strings.HasPrefix(trimmed, ".") {
return trimmed
}
return "." + trimmed
}
func decodeBase64HTML(encoded string) decodedHTML {
encoded = strings.TrimSpace(encoded)
if encoded == "" {
return decodedHTML{}
}
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return decodedHTML{}
}
return decodedHTML{Text: string(decoded), Valid: true}
}
func combineHTMLSections(description, documentation decodedHTML) string {
switch {
case description.Valid && documentation.Valid:
return description.Text + "\n\n<hr />\n\n<h2>Documentation</h2>\n" + documentation.Text
case description.Valid:
return description.Text
case documentation.Valid:
return documentation.Text
default:
return ""
}
}
func appendLinkURLs(info *catalog.Metadata, links map[string]string) {
for key, rawValue := range links {
value := normalizeSpigetURL(rawValue)
if value == "" {
continue
}
name, kind := classifySpigetLink(key)
info.URLs = append(
info.URLs,
catalog.URL{Name: name, Type: kind, URL: value},
)
}
}
func normalizeSpigetURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
return raw
}
if strings.HasPrefix(raw, "/") {
return spigotWebsiteBaseURL[:len(spigotWebsiteBaseURL)-1] + raw
}
if strings.HasPrefix(raw, "threads/") || strings.HasPrefix(
raw,
"resources/",
) || strings.HasPrefix(raw, "members/") {
return spigotWebsiteBaseURL + raw
}
return raw
}
func classifySpigetLink(key string) (string, catalog.URLType) {
switch key {
case "additionalInformation":
return "Additional information", catalog.URLHome
case "alternativeSupport":
return "Support", catalog.URLForum
case "discussion":
return "Discussion", catalog.URLForum
}
decoded := decodeBase64HTML(key)
if decoded.Valid {
if _, err := url.ParseRequestURI(decoded.Text); err == nil {
return "Link", catalog.URLMisc
}
return prettifyLinkName(decoded.Text), catalog.URLMisc
}
return prettifyLinkName(key), catalog.URLMisc
}
func prettifyLinkName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "Link"
}
replacer := strings.NewReplacer("-", " ", "_", " ")
name = replacer.Replace(name)
return strings.ToUpper(name[:1]) + name[1:]
}
func normalizedProjectName(name string) catalog.Name {
name = input.ToProjectName(name).String()
var b strings.Builder
b.Grow(len(name))
lastHyphen := false
for _, r := range name {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(unicode.ToLower(r))
lastHyphen = false
case !lastHyphen:
b.WriteByte('-')
lastHyphen = true
}
}
return catalog.Name(strings.Trim(b.String(), "-"))
}
package spiget
import (
"context"
"encoding/json/v2"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
)
func (p *Provider) requestJSON(
ctx context.Context,
requestURL string,
out any,
notFound error,
) error {
p.logger.Debug("spiget api: GET " + requestURL)
resp, err := p.network.CachedGetRequest(
ctx,
requestURL,
cache.BytesRequestOptions{Kind: cache.KindMetadata},
)
if err != nil {
return fmt.Errorf("spiget: request failed: %w", err)
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound && notFound != nil {
return notFound
}
return unexpectedStatusError(requestURL, resp.StatusCode)
}
if err := json.Unmarshal(resp.Data, out); err != nil {
return fmt.Errorf("spiget: failed to decode response: %w", err)
}
return nil
}
func (p *Provider) searchResources(
ctx context.Context,
query string,
) (
searchResponse,
error,
) {
u := searchResourcesURL(query)
resp := searchResponse{}
if err := p.requestJSON(ctx, u, &resp, nil); err != nil {
return nil, err
}
return resp, nil
}
func (p *Provider) getResource(
ctx context.Context,
id int64,
) (*resourceResponse, error) {
resp := &resourceResponse{}
if err := p.requestJSON(ctx, resourceURL(id), resp, ErrNoProject); err != nil {
return nil, err
}
return resp, nil
}
func (p *Provider) getLatestVersion(
ctx context.Context,
resourceID int64,
) (*versionResponse, error) {
resp := &versionResponse{}
if err := p.requestJSON(
ctx,
latestVersionURL(resourceID),
resp,
ErrNoVersion,
); err != nil {
return nil, err
}
return resp, nil
}
const spigetVersionPageSize = 1000
func (p *Provider) listVersions(
ctx context.Context,
resourceID int64,
) ([]versionResponse, error) {
var versions []versionResponse
for page := 1; ; page++ {
var resp []versionResponse
if err := p.requestJSON(
ctx,
versionsURL(resourceID, page),
&resp,
ErrNoVersion,
); err != nil {
return nil, err
}
versions = append(versions, resp...)
if len(resp) < spigetVersionPageSize {
break
}
}
return versions, nil
}
func searchResourcesURL(query string) string {
values := url.Values{}
values.Set("size", "20")
return spigetAPIBaseURL + "/search/resources/" + url.PathEscape(query) + "?" + values.Encode()
}
func resourceURL(id int64) string {
return spigetAPIBaseURL + "/resources/" + strconv.FormatInt(id, 10)
}
func latestVersionURL(resourceID int64) string {
return resourceURL(resourceID) + "/versions/latest"
}
func versionsURL(resourceID int64, page int) string {
values := url.Values{}
values.Set("size", strconv.Itoa(spigetVersionPageSize))
values.Set("page", strconv.Itoa(page))
values.Set("sort", "-releaseDate")
return resourceURL(resourceID) + "/versions?" + values.Encode()
}
func parseNumericResourceID(name catalog.Name) (int64, bool) {
trimmed := strings.TrimSpace(name.String())
if trimmed == "" {
return 0, false
}
id, err := strconv.ParseInt(trimmed, 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}
package spiget
import (
"context"
"strings"
"github.com/mclucy/lucy/catalog"
versionpkg "github.com/mclucy/lucy/version"
)
func (p *Provider) resolveResourceByProjectName(
ctx context.Context,
name catalog.Name,
) (
*resourceResponse,
error,
) {
if id, ok := parseNumericResourceID(name); ok {
return p.getResource(ctx, id)
}
results, err := p.searchResources(ctx, name.String())
if err != nil {
return nil, err
}
for _, candidate := range results {
if normalizedProjectName(candidate.Name) == name || strings.EqualFold(
candidate.Name,
name.String(),
) {
return p.getResource(ctx, candidate.ID)
}
}
if len(results) == 1 {
return p.getResource(ctx, results[0].ID)
}
return nil, ErrNoProject
}
func (p *Provider) resolveVersion(
ctx context.Context,
resource *resourceResponse,
requested versionpkg.Version,
) (resolvedVersion, error) {
switch requested {
case "", versionpkg.Any, versionpkg.Beta, versionpkg.Stable:
latest, err := p.getLatestVersion(ctx, resource.ID)
if err != nil {
return resolvedVersion{}, err
}
return NewResolvedVersion(*resource, *latest), nil
}
versions, err := p.listVersions(ctx, resource.ID)
if err != nil {
return resolvedVersion{}, err
}
for _, version := range versions {
resolved := NewResolvedVersion(*resource, version)
if resolved.Matches(requested) {
return resolved, nil
}
}
return resolvedVersion{}, ErrNoVersion
}
// Package routing composes configured provider instances with provider
// selection and execution policy.
//
// Responsibilities:
// - Resolve ProviderUnspecified against an Ecosystem into ordered provider
// candidates.
// - Map an explicit Provider (or Registry for search) to exactly one
// capability when supported.
// - Apply operation-aware routing policy (search/info/fetch/dependencies).
// - Return typed selection errors for invalid/unsupported inputs.
//
// Non-responsibilities:
// - Do not aggregate or merge upstream result payloads.
package routing
import (
"errors"
"fmt"
"strings"
"github.com/mclucy/lucy/cache"
catalogpkg "github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/log"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/providers/bmclapi"
"github.com/mclucy/lucy/upstream/providers/curseforge"
"github.com/mclucy/lucy/upstream/providers/fabric"
"github.com/mclucy/lucy/upstream/providers/forge"
"github.com/mclucy/lucy/upstream/providers/hangar"
"github.com/mclucy/lucy/upstream/providers/mcdr"
"github.com/mclucy/lucy/upstream/providers/modrinth"
"github.com/mclucy/lucy/upstream/providers/mojang"
"github.com/mclucy/lucy/upstream/providers/neoforge"
"github.com/mclucy/lucy/upstream/providers/spiget"
)
var (
ErrUnknownProvider = errors.New("unknown provider")
ErrUnsupportedProvider = errors.New("unsupported provider")
ErrInvalidEcosystem = errors.New("cannot find providers for ecosystem")
ErrNoCatalogue = errors.New("registry has no package catalogue")
)
type providerCatalog struct {
packageProviders map[catalogpkg.Provider]upstream.PackageProvider
searchProviders map[catalogpkg.Registry]upstream.SearchProvider
infoProviders map[catalogpkg.Provider]upstream.InfoProvider
artifactMappers map[catalogpkg.Provider]upstream.ArtifactMapProvider
installerProviders map[catalogpkg.Provider]upstream.InstallerProvider
}
// Router binds configured provider instances to their capabilities and applies
// provider selection policy. One Router belongs to one invocation: providers
// own the network cache, logger, and API keys they were constructed with.
type Router struct {
logger *log.Logger
network *cache.Client
providers providerCatalog
modrinth *modrinth.Provider
curseforge *curseforge.Provider
hangar *hangar.Provider
spiget *spiget.Provider
mcdr *mcdr.Provider
mojang *mojang.Provider
fabric *fabric.Provider
forge *forge.Provider
neoforge *neoforge.Provider
}
// New constructs a router over provider instances. network carries HTTP and
// cache configuration for metadata and artifact requests; logger may be nil.
// curseforgeAPIKey is the effective key: an empty key leaves CurseForge
// unregistered. Construction performs no network requests and decodes no key.
func New(
network *cache.Client,
logger *log.Logger,
curseforgeAPIKey string,
) *Router {
router := &Router{
logger: logger,
network: network,
modrinth: modrinth.New(network, logger),
hangar: hangar.New(network, logger),
spiget: spiget.New(network, logger),
mcdr: mcdr.New(network, logger),
mojang: mojang.New(network),
fabric: fabric.New(network),
forge: forge.New(network),
neoforge: neoforge.New(network),
}
router.providers = providerCatalog{
packageProviders: map[catalogpkg.Provider]upstream.PackageProvider{
catalogpkg.ProviderModrinth: router.modrinth,
catalogpkg.ProviderMCDR: router.mcdr,
catalogpkg.ProviderHangar: router.hangar,
catalogpkg.ProviderSpiget: router.spiget,
},
searchProviders: map[catalogpkg.Registry]upstream.SearchProvider{
catalogpkg.RegistryModrinth: router.modrinth,
catalogpkg.RegistryMCDR: router.mcdr,
catalogpkg.RegistryHangar: router.hangar,
catalogpkg.RegistrySpiget: router.spiget,
},
infoProviders: map[catalogpkg.Provider]upstream.InfoProvider{
catalogpkg.ProviderModrinth: router.modrinth,
catalogpkg.ProviderMCDR: router.mcdr,
catalogpkg.ProviderHangar: router.hangar,
catalogpkg.ProviderSpiget: router.spiget,
},
artifactMappers: map[catalogpkg.Provider]upstream.ArtifactMapProvider{
catalogpkg.ProviderModrinth: router.modrinth,
},
installerProviders: map[catalogpkg.Provider]upstream.InstallerProvider{
catalogpkg.ProviderBMCLAPI: bmclapi.New(
router.mojang,
router.forge,
router.neoforge,
),
catalogpkg.ProviderFabric: router.fabric,
catalogpkg.ProviderForge: router.forge,
catalogpkg.ProviderMojang: router.mojang,
catalogpkg.ProviderNeoForge: router.neoforge,
},
}
if key := strings.TrimSpace(curseforgeAPIKey); key != "" {
router.curseforge = curseforge.New(network, logger, key)
router.providers.packageProviders[catalogpkg.ProviderCurseForge] = router.curseforge
router.providers.searchProviders[catalogpkg.RegistryCurseForge] = router.curseforge
router.providers.infoProviders[catalogpkg.ProviderCurseForge] = router.curseforge
router.providers.artifactMappers[catalogpkg.ProviderCurseForge] = router.curseforge
}
return router
}
func (r *Router) curseforgeAvailable() bool {
return r.curseforge != nil
}
// ListAutoProviders returns the default ordered provider list used when the
// provider is unspecified and the platform is unspecified.
func (r *Router) ListAutoProviders() []upstream.PackageProvider {
capabilities, _ := r.packageProvidersFromKeys(
autoProviders(r.curseforgeAvailable()),
)
return capabilities
}
// GetArtifactMapper returns the hash mapper registered for provider.
func (r *Router) GetArtifactMapper(
provider catalogpkg.Provider,
) (upstream.ArtifactMapProvider, bool, error) {
mapper, ok := r.providers.artifactMappers[provider]
if !ok {
r.logger.Debug(
"routing: no artifact mapper for provider",
"provider", provider,
"reason", "provider has no artifact mapper",
)
}
return mapper, ok, nil
}
func (r *Router) artifactMappers() []upstream.ArtifactMapProvider {
providers := modProviders(r.curseforgeAvailable())
mappers := make([]upstream.ArtifactMapProvider, 0, len(providers))
for _, provider := range providers {
mapper, ok := r.providers.artifactMappers[provider]
if ok {
mappers = append(mappers, mapper)
}
}
return mappers
}
// InstallerFor returns the installer provider that can bootstrap the
// ecosystem.
func (r *Router) InstallerFor(ecosystem catalogpkg.Ecosystem) (
upstream.InstallerProvider,
bool,
) {
provider, ok := installerProviderForEcosystem(ecosystem)
if !ok {
return nil, false
}
return r.InstallerForProvider(provider)
}
// InstallerForProvider returns the installer provider registered for the
// provider key.
func (r *Router) InstallerForProvider(provider catalogpkg.Provider) (
upstream.InstallerProvider,
bool,
) {
installer, ok := r.providers.installerProviders[provider]
if !ok {
r.logger.Debug(
"routing: no installer provider",
"provider", provider,
"reason", "provider has no installer capability",
)
}
return installer, ok
}
func installerProviderForEcosystem(ecosystem catalogpkg.Ecosystem) (
catalogpkg.Provider,
bool,
) {
switch ecosystem {
case catalogpkg.EcosystemMinecraft:
return catalogpkg.ProviderMojang, true
case catalogpkg.EcosystemForge:
return catalogpkg.ProviderForge, true
case catalogpkg.EcosystemNeoForge:
return catalogpkg.ProviderNeoForge, true
case catalogpkg.EcosystemFabric:
return catalogpkg.ProviderFabric, true
default:
return catalogpkg.ProviderUnknown, false
}
}
// ResolveProviders resolves ordered provider candidates for the platform and
// user-specified provider.
func (r *Router) ResolveProviders(
platform catalogpkg.Ecosystem,
provider catalogpkg.Provider,
) ([]upstream.PackageProvider, error) {
if provider == catalogpkg.ProviderUnknown {
return nil, ErrUnknownProvider
}
if provider != catalogpkg.ProviderUnspecified {
return r.resolveExplicitProvider(provider)
}
keys, err := providersForEcosystem(platform, r.curseforgeAvailable())
if err != nil {
r.logger.Debug(
"routing: package providers unavailable for ecosystem",
"platform", platform,
"reason", "unsupported ecosystem",
)
return nil, fmt.Errorf("%w: %s", err, platform)
}
providers, err := r.packageProvidersFromKeys(keys)
if err != nil {
return nil, err
}
r.logger.Debug(
"routing: package providers selected from ecosystem candidates",
"platform", platform,
"providers", keys,
)
return providers, nil
}
// ResolveSearchProviders resolves search providers for the platform. With a
// platform filter active, explicit registry selection is validated against
// registry capability data.
func (r *Router) ResolveSearchProviders(
platform catalogpkg.Ecosystem,
registry catalogpkg.Registry,
) ([]upstream.SearchProvider, error) {
if registry == catalogpkg.RegistryUnknown {
return nil, ErrUnknownProvider
}
if registry != catalogpkg.RegistryUnspecified {
if err := validateSearchRegistryEcosystem(registry, platform); err != nil {
r.logger.Debug(
"routing: search registry rejected for platform",
"registry", registry,
"platform", platform,
"reason", "registry does not support platform",
)
return nil, err
}
return r.resolveExplicitRegistry(registry)
}
if !platform.IsSearchable() {
keys, err := providersForEcosystem(platform, r.curseforgeAvailable())
if err != nil {
r.logger.Debug(
"routing: search providers unavailable for ecosystem",
"platform", platform,
"reason", "unsupported ecosystem",
)
return nil, fmt.Errorf("%w: %s", err, platform)
}
providers, err := r.searchersFromKeys(keys)
if err != nil {
return nil, err
}
r.logger.Debug(
"routing: search providers selected from ecosystem candidates",
"platform", platform,
"providers", keys,
)
return providers, nil
}
keys := searchProvidersForEcosystem(platform, r.curseforgeAvailable())
if len(keys) == 0 {
r.logger.Debug(
"routing: search providers unavailable for platform",
"platform", platform,
"reason", "no provider supports platform search",
)
return nil, fmt.Errorf("%w: %s", ErrInvalidEcosystem, platform)
}
providers, err := r.searchersFromKeys(keys)
if err != nil {
return nil, err
}
r.logger.Debug(
"routing: search providers selected for platform",
"platform", platform,
"providers", keys,
)
return providers, nil
}
// ResolveInfoProviders resolves info providers for the platform and
// user-specified provider.
func (r *Router) ResolveInfoProviders(
platform catalogpkg.Ecosystem,
provider catalogpkg.Provider,
) ([]upstream.InfoProvider, error) {
if provider == catalogpkg.ProviderUnknown {
return nil, ErrUnknownProvider
}
if provider != catalogpkg.ProviderUnspecified {
return r.resolveExplicitInformer(provider)
}
keys, err := providersForEcosystem(platform, r.curseforgeAvailable())
if err != nil {
r.logger.Debug(
"routing: info providers unavailable for ecosystem",
"platform", platform,
"reason", "unsupported ecosystem",
)
return nil, fmt.Errorf("%w: %s", err, platform)
}
providers, err := r.informersFromKeys(keys)
if err != nil {
return nil, err
}
r.logger.Debug(
"routing: info providers selected from ecosystem candidates",
"platform", platform,
"providers", keys,
)
return providers, nil
}
func (r *Router) resolveExplicitInformer(provider catalogpkg.Provider) (
[]upstream.InfoProvider,
error,
) {
informer, ok := r.providers.infoProviders[provider]
if !ok {
r.logger.Debug(
"routing: explicit info provider rejected",
"provider", provider,
"reason", "provider has no info capability",
)
return nil, fmt.Errorf("%w: %s", ErrUnsupportedProvider, provider)
}
r.logger.Debug(
"routing: explicit info provider selected",
"provider", provider,
"capability", informer.Provider(),
)
return []upstream.InfoProvider{informer}, nil
}
func (r *Router) resolveExplicitRegistry(registry catalogpkg.Registry) (
[]upstream.SearchProvider,
error,
) {
searcher, ok := r.providers.searchProviders[registry]
if !ok {
r.logger.Debug(
"routing: explicit search registry rejected",
"registry", registry,
"reason", "registry has no search capability",
)
return nil, fmt.Errorf("%w: %s", ErrUnsupportedProvider, registry)
}
r.logger.Debug(
"routing: explicit search registry selected",
"registry", registry,
"provider", searcher.Provider(),
)
return []upstream.SearchProvider{searcher}, nil
}
// ResolveProvidersForRuntime resolves package providers from the runtime's
// ecosystems and the user-specified provider.
func (r *Router) ResolveProvidersForRuntime(
ecosystems []catalogpkg.Ecosystem,
provider catalogpkg.Provider,
) ([]upstream.PackageProvider, error) {
if provider == catalogpkg.ProviderUnknown {
return nil, ErrUnknownProvider
}
if provider != catalogpkg.ProviderUnspecified {
return r.resolveExplicitProvider(provider)
}
if len(ecosystems) == 0 {
r.logger.Debug(
"routing: package providers unavailable for runtime",
"reason", "no runtime ecosystems",
)
return nil, fmt.Errorf("routing: runtime ecosystems unavailable, cannot resolve providers")
}
selection := providersFromEcosystems(ecosystems, r.curseforgeAvailable())
switch {
case len(selection.providers) > 0:
r.logger.Debug(
"routing: package providers selected from runtime ecosystems",
"ecosystems", ecosystems,
"providers", selection.providers,
)
return r.packageProvidersFromKeys(selection.providers)
case selection.fallback:
r.logger.Debug(
"routing: package providers fell back to auto list",
"ecosystems", ecosystems,
"reason", "no runtime ecosystem maps to package providers",
)
return r.ListAutoProviders(), nil
default:
r.logger.Debug(
"routing: package provider selection is empty",
"ecosystems", ecosystems,
"reason", "runtime ecosystems carry no package providers",
)
return []upstream.PackageProvider{}, nil
}
}
func (r *Router) resolveExplicitProvider(provider catalogpkg.Provider) (
[]upstream.PackageProvider,
error,
) {
capability, ok := r.providers.packageProviders[provider]
if !ok {
r.logger.Debug(
"routing: explicit package provider rejected",
"provider", provider,
"reason", "provider has no package capability",
)
return nil, fmt.Errorf("%w: %s", ErrUnsupportedProvider, provider)
}
r.logger.Debug(
"routing: explicit package provider selected",
"provider", provider,
"capability", capability.Provider(),
)
return []upstream.PackageProvider{capability}, nil
}
func validateSearchRegistryEcosystem(
registry catalogpkg.Registry,
platform catalogpkg.Ecosystem,
) error {
if !platform.IsSearchable() {
return nil
}
provider := registry.Provider()
support, ok := EcosystemSupportedBy(provider, platform)
if ok && support.Supported {
return nil
}
return fmt.Errorf(
"registry %s does not support platform %s",
registry,
platform,
)
}
func (r *Router) packageProvidersFromKeys(providers []catalogpkg.Provider) (
[]upstream.PackageProvider,
error,
) {
capabilities := make([]upstream.PackageProvider, 0, len(providers))
for _, provider := range providers {
capability, ok := r.providers.packageProviders[provider]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnsupportedProvider, provider)
}
capabilities = append(capabilities, capability)
}
return capabilities, nil
}
func (r *Router) searchersFromKeys(keys []catalogpkg.Provider) (
[]upstream.SearchProvider,
error,
) {
capabilities := make([]upstream.SearchProvider, 0, len(keys))
for _, key := range keys {
registry, ok := key.Registry()
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnsupportedProvider, key)
}
capability, ok := r.providers.searchProviders[registry]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnsupportedProvider, key)
}
capabilities = append(capabilities, capability)
}
return capabilities, nil
}
func (r *Router) informersFromKeys(keys []catalogpkg.Provider) (
[]upstream.InfoProvider,
error,
) {
capabilities := make([]upstream.InfoProvider, 0, len(keys))
for _, key := range keys {
capability, ok := r.providers.infoProviders[key]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrUnsupportedProvider, key)
}
capabilities = append(capabilities, capability)
}
return capabilities, nil
}
package routing
import (
"context"
"fmt"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
)
// Builds lists the published versions of an explicitly identified project.
func (r *Router) Builds(
ctx context.Context,
ref catalog.Ref,
) ([]upstream.Build, error) {
project := ref.Name.String()
switch ref.Provider {
case catalog.ProviderModrinth:
return r.modrinth.Builds(ctx, project)
case catalog.ProviderCurseForge:
if r.curseforge == nil {
return nil, fmt.Errorf("%w: %s", ErrUnsupportedProvider, ref.Provider)
}
return r.curseforge.Builds(ctx, project)
case catalog.ProviderHangar:
return r.hangar.Builds(ctx, project)
case catalog.ProviderSpiget:
return r.spiget.Builds(ctx, project)
case catalog.ProviderMCDR:
return r.mcdr.Builds(ctx, project)
default:
return nil, fmt.Errorf("%s does not list builds", ref.Provider)
}
}
package routing
import "github.com/mclucy/lucy/catalog"
// SearchEcosystemSupport describes how a provider can participate in search for a
// given ecosystem.
type SearchEcosystemSupport struct {
// Supported reports whether the provider can serve this platform at all.
Supported bool
// UpstreamFilterable reports whether the provider can apply the platform filter
// upstream instead of requiring post-filtering.
UpstreamFilterable bool
}
// SearchCapability describes static search capabilities for a provider.
//
// This is a struct instead of an interface so additional capability dimensions
// can be added later without breaking callers.
type SearchCapability struct {
Ecosystems map[catalog.Ecosystem]SearchEcosystemSupport
}
var unsupportedSearchEcosystem = SearchEcosystemSupport{}
var searchCapabilityByProvider = map[catalog.Provider]SearchCapability{
catalog.ProviderModrinth: {
Ecosystems: map[catalog.Ecosystem]SearchEcosystemSupport{
catalog.EcosystemFabric: {Supported: true, UpstreamFilterable: true},
catalog.EcosystemForge: {Supported: true, UpstreamFilterable: true},
catalog.EcosystemNeoForge: {Supported: true, UpstreamFilterable: true},
catalog.EcosystemBukkit: {Supported: true, UpstreamFilterable: true},
catalog.EcosystemPaper: {Supported: true, UpstreamFilterable: true},
},
},
catalog.ProviderCurseForge: {
Ecosystems: map[catalog.Ecosystem]SearchEcosystemSupport{
catalog.EcosystemFabric: {Supported: true, UpstreamFilterable: true},
catalog.EcosystemForge: {Supported: true, UpstreamFilterable: true},
catalog.EcosystemNeoForge: {Supported: true, UpstreamFilterable: true},
catalog.EcosystemBukkit: unsupportedSearchEcosystem,
},
},
catalog.ProviderHangar: {
Ecosystems: map[catalog.Ecosystem]SearchEcosystemSupport{
catalog.EcosystemFabric: unsupportedSearchEcosystem,
catalog.EcosystemForge: unsupportedSearchEcosystem,
catalog.EcosystemNeoForge: unsupportedSearchEcosystem,
catalog.EcosystemBukkit: {Supported: true, UpstreamFilterable: true},
catalog.EcosystemPaper: {Supported: true, UpstreamFilterable: true},
},
},
catalog.ProviderSpiget: {
Ecosystems: map[catalog.Ecosystem]SearchEcosystemSupport{
catalog.EcosystemFabric: unsupportedSearchEcosystem,
catalog.EcosystemForge: unsupportedSearchEcosystem,
catalog.EcosystemNeoForge: unsupportedSearchEcosystem,
catalog.EcosystemBukkit: {
Supported: true,
UpstreamFilterable: false,
},
catalog.EcosystemPaper: {
Supported: true,
UpstreamFilterable: false,
},
},
},
catalog.ProviderMCDR: {
Ecosystems: map[catalog.Ecosystem]SearchEcosystemSupport{
catalog.EcosystemFabric: unsupportedSearchEcosystem,
catalog.EcosystemForge: unsupportedSearchEcosystem,
catalog.EcosystemNeoForge: unsupportedSearchEcosystem,
catalog.EcosystemBukkit: unsupportedSearchEcosystem,
},
},
}
// SearchCapabilityFor returns static search capability metadata for a provider.
func SearchCapabilityFor(provider catalog.Provider) (SearchCapability, bool) {
capability, ok := searchCapabilityByProvider[provider]
return capability, ok
}
func EcosystemSupportedBy(
provider catalog.Provider,
ecosystem catalog.Ecosystem,
) (SearchEcosystemSupport, bool) {
capability, ok := SearchCapabilityFor(provider)
if !ok {
return SearchEcosystemSupport{}, false
}
support, ok := capability.Ecosystems[ecosystem]
return support, ok
}
package routing
import (
"context"
"fmt"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
)
// Candidates routes explicit project identities, never context aliases.
func (r *Router) Candidates(
ctx context.Context,
registry catalog.Registry,
project, minecraft string,
loader catalog.Ecosystem,
) ([]upstream.Candidate, error) {
r.logger.Debug(
"routing: catalogue candidates requested",
"registry", registry,
"project", project,
"minecraft", minecraft,
"loader", loader,
)
switch registry {
case catalog.RegistryModrinth:
return r.modrinth.Candidates(ctx, project, minecraft, loader)
case catalog.RegistryCurseForge:
if r.curseforge == nil {
return nil, fmt.Errorf("%w: %q", ErrNoCatalogue, registry)
}
return r.curseforge.Candidates(ctx, project, minecraft, loader)
case catalog.RegistryMCDR:
return r.mcdr.Candidates(ctx, project, minecraft, loader)
default:
r.logger.Debug(
"routing: catalogue registry rejected",
"registry", registry,
"reason", "registry has no package catalogue",
)
return nil, fmt.Errorf("%w: %q", ErrNoCatalogue, registry)
}
}
package routing
import (
"context"
"encoding/json/v2"
"fmt"
"net/url"
"strings"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/cache"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/version"
)
// CoreModules reports the descriptor modules the server core artifact ships.
// A lock may predate module recording, so the artifact is read when the lock
// carries none. Only Fabric bundles mods inside its loader.
func CoreModules(
ctx context.Context,
product server.Product,
core catalog.Artifact,
acquire func(*catalog.Artifact) (artifact.MaterializedFile, error),
) ([]catalog.DescriptorModule, error) {
if product != server.ProductFabric {
return nil, nil
}
if len(core.Modules) > 0 {
return core.Modules, nil
}
materialized, err := acquire(&core)
if err != nil {
return nil, err
}
return artifact.ReadModules(ctx, materialized.Path, catalog.EcosystemFabric)
}
// Metadata fetches JSON metadata through the router's configured network.
func (r *Router) Metadata(
ctx context.Context,
endpoint string,
target any,
) error {
if err := ctx.Err(); err != nil {
return err
}
r.logger.Debug("routing: metadata request", "endpoint", endpoint)
data, err := r.network.CachedGetBytes(
ctx,
endpoint,
cache.BytesRequestOptions{Kind: cache.KindMetadata},
)
if err != nil {
return err
}
return json.Unmarshal(data, target)
}
// trustMavenChecksum adopts the digest the artifact's Maven repository
// publishes beside it. Acquisition records whatever digest it computes when
// nothing is supplied to compare against, so a core artifact without a trusted
// checksum would pin unverified bytes in the lock. The repository sidecar is
// that source, and an unavailable one fails the resolution rather than
// lowering the guarantee.
func (r *Router) trustMavenChecksum(
ctx context.Context,
a *catalog.Artifact,
) error {
if err := ctx.Err(); err != nil {
return err
}
data, err := r.network.CachedGetBytes(
ctx,
a.URL+".sha256",
cache.BytesRequestOptions{Kind: cache.KindMetadata},
)
if err != nil {
return fmt.Errorf("no trusted checksum for %s: %w", a.Filename, err)
}
sum := strings.TrimSpace(string(data))
if len(sum) != 64 {
return fmt.Errorf("malformed checksum for %s", a.Filename)
}
a.Hashes["sha256"] = sum
r.logger.Debug(
"routing: trusted checksum adopted",
"artifact", a.Filename,
"sha256", sum,
)
return nil
}
// ResolveServerProduct resolves the server product artifact, its download
// inputs, and the Java advisory for the requested Minecraft version. The
// advisory comes from Mojang's per-version metadata. It always acquires the
// selected core because the lock records the bytes it pins.
func (r *Router) ResolveServerProduct(
ctx context.Context,
req ServerProductRequest,
acquire func(*catalog.Artifact) (artifact.MaterializedFile, error),
) (ServerProductResolution, error) {
out := ServerProductResolution{
Minecraft: req.Minecraft,
Product: req.Product,
ProductVersion: req.ProductVersion,
}
advisory, err := r.mojang.JavaAdvisory(ctx, req.Minecraft)
if err != nil {
return out, err
}
out.JavaAdvisory = advisory
local := upstream.LocalContext{
Ecosystem: catalog.EcosystemMinecraft,
MinecraftVersion: req.Minecraft,
}
vanilla, err := r.mojang.Fetch(
ctx,
local,
catalog.VersionedRef{
Name: "minecraft",
Ecosystem: catalog.EcosystemMinecraft,
Version: req.Minecraft,
},
)
if err != nil {
return out, err
}
minecraft := catalog.Artifact{
Provider: catalog.ProviderMojang,
ProjectID: "minecraft",
Version: req.Minecraft,
Filename: "server.jar",
URL: vanilla.FileURL,
Hashes: map[string]string{"sha1": vanilla.Hash},
}
if req.Product == server.ProductMinecraft {
r.logger.Debug(
"routing: server product route selected",
"product", req.Product,
"provider", catalog.ProviderMojang,
)
out.Artifact = minecraft
out.ProductVersion = req.Minecraft
materialized, err := acquire(&out.Artifact)
if err != nil {
return out, err
}
out.Artifact.Hashes = materialized.Hashes
return out, nil
}
out.Inputs = []server.BootstrapInput{
{
Path: "server.jar",
Artifact: minecraft,
},
}
if req.Repository != "" {
r.logger.Debug(
"routing: server product route selected",
"product", req.Product,
"provider", catalog.ProviderGitHub,
"repository", req.Repository,
)
err = r.resolveGithubProduct(ctx, req, &out, acquire)
} else {
switch req.Product {
case server.ProductFabric:
selected, e := r.fabric.ResolveVersionSelector(
ctx,
local,
catalog.VersionedRef{
Name: "fabric",
Ecosystem: catalog.EcosystemFabric,
Version: req.ProductVersion,
},
)
if e != nil {
return out, e
}
out.ProductVersion = selected.Version
out.Bootstrapper = "fabric"
r.logger.Debug(
"routing: server product route selected",
"product", req.Product,
"provider", catalog.ProviderFabric,
"version", out.ProductVersion,
)
var profile struct {
MainClass string `json:"mainClass"`
Libraries []mavenLibrary `json:"libraries"`
}
if e := r.Metadata(
ctx,
"https://meta.fabricmc.net/v2/versions/loader/"+
url.PathEscape(req.Minecraft.String())+"/"+
url.PathEscape(out.ProductVersion.String())+
"/server/json",
&profile,
); e != nil {
return out, e
}
out.Artifact = catalog.Artifact{
Provider: catalog.ProviderFabric,
ProjectID: "fabric-loader",
Version: out.ProductVersion,
Filename: "fabric-loader-" + out.ProductVersion.String() + ".jar",
URL: "https://maven.fabricmc.net/net/fabricmc/fabric-loader/" +
out.ProductVersion.String() + "/fabric-loader-" +
out.ProductVersion.String() + ".jar",
Hashes: map[string]string{},
}
if e := r.trustMavenChecksum(ctx, &out.Artifact); e != nil {
return out, e
}
materialized, e := acquire(&out.Artifact)
if e != nil {
return out, e
}
out.Artifact.Hashes = materialized.Hashes
modules, e := artifact.ReadModules(
ctx,
materialized.Path,
catalog.EcosystemFabric,
)
if e != nil {
return out, e
}
out.Artifact.Modules = modules
for _, lib := range profile.Libraries {
input, downloadable, e := libraryInput(
lib,
nil,
out.ProductVersion,
)
if e != nil {
return out, e
}
if !downloadable || input.Artifact.URL == out.Artifact.URL {
continue
}
out.Inputs = append(out.Inputs, input)
}
case server.ProductForge, server.ProductNeoForge:
var (
remote upstream.ResolvedPackage
provider catalog.Provider
)
if req.Product == server.ProductForge {
provider = catalog.ProviderForge
remote, err = r.forge.Fetch(
ctx,
local,
catalog.VersionedRef{
Name: "forge",
Ecosystem: catalog.EcosystemForge,
Version: req.ProductVersion,
},
)
} else {
provider = catalog.ProviderNeoForge
remote, err = r.neoforge.Fetch(
ctx,
local,
catalog.VersionedRef{
Name: "neoforge",
Ecosystem: catalog.EcosystemNeoForge,
Version: req.ProductVersion,
},
)
}
if err != nil {
return out, err
}
out.Artifact = catalog.Artifact{
Provider: provider,
ProjectID: req.Product.String(),
Version: req.ProductVersion,
Filename: remote.Filename,
URL: remote.FileURL,
Hashes: map[string]string{},
}
materialized, e := acquire(&out.Artifact)
if e != nil {
return out, e
}
out.Artifact.Hashes = materialized.Hashes
if err := r.resolveInstaller(ctx, req, &out, materialized.Path); err != nil {
return out, err
}
r.logger.Debug(
"routing: server product route selected",
"product", req.Product,
"provider", provider,
"version", out.ProductVersion,
)
case server.ProductPaper:
err = r.resolvePaper(ctx, req, &out)
case server.ProductPurpur:
build := req.ProductVersion
if floating(build) {
build = "latest"
}
var info struct {
Build string `json:"build"`
Version string `json:"version"`
MD5 string `json:"md5"`
}
if e := r.Metadata(
ctx,
"https://api.purpurmc.org/v2/purpur/"+
req.Minecraft.String()+"/"+build.String(),
&info,
); e != nil {
return out, e
}
if info.Version != req.Minecraft.String() {
return out, fmt.Errorf(
"purpur release targets Minecraft %s, requested %s",
info.Version,
req.Minecraft,
)
}
out.ProductVersion = version.Version(info.Build)
out.Artifact = catalog.Artifact{
Provider: catalog.ProviderPurpur,
ProjectID: "purpur",
ReleaseID: info.Build,
Version: version.Version(info.Build),
Filename: "purpur-" + req.Minecraft.String() + "-" +
info.Build + ".jar",
URL: "https://api.purpurmc.org/v2/purpur/" +
req.Minecraft.String() + "/" + info.Build + "/download",
Hashes: map[string]string{"md5": info.MD5},
}
out.Bootstrapper = "paperclip"
r.logger.Debug(
"routing: server product route selected",
"product", req.Product,
"provider", catalog.ProviderPurpur,
"build", info.Build,
)
default:
r.logger.Debug(
"routing: server product route rejected",
"product", req.Product,
"reason", "unsupported server product",
)
return out, fmt.Errorf("unsupported server product %q", req.Product)
}
}
if err != nil {
return out, err
}
materialized, err := acquire(&out.Artifact)
if err != nil {
return out, err
}
out.Artifact.Hashes = materialized.Hashes
if out.Bootstrapper == "paperclip" {
if err := paperclipInput(&out, materialized.Path); err != nil {
return out, err
}
}
for i := range out.Inputs {
if out.Inputs[i].Embedded == "" {
input, e := acquire(&out.Inputs[i].Artifact)
if e != nil {
return out, e
}
out.Inputs[i].Artifact.Hashes = input.Hashes
}
}
return out, nil
}
func floating(v version.Version) bool {
switch v {
case "", version.Any, version.Beta, version.Stable, "latest":
return true
default:
return false
}
}
package routing
import (
"context"
"errors"
"fmt"
"sync"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
)
var ErrNoProviderSucceeded = errors.New("no provider succeeded")
type ProviderError struct {
// Provider identifies the semantic upstream label for user-facing diagnostics.
// The failed runtime executor is a Provider implementation.
Provider catalog.Provider
Err error
}
func (e ProviderError) Error() string {
return fmt.Sprintf("%s: %v", e.Provider.String(), e.Err)
}
func (e ProviderError) Unwrap() error {
return e.Err
}
// SearchMany executes search on all providers in parallel.
//
// Default behavior is non-aggregated: each provider contributes one
// upstream.SearchResponse item in the returned slice.
func (r *Router) SearchMany(
ctx context.Context,
providers []upstream.SearchProvider,
query upstream.Query,
) ([]upstream.SearchResponse, []ProviderError) {
if len(providers) == 0 {
return nil, nil
}
type slot struct {
res upstream.SearchResponse
err ProviderError
ok bool
failed bool
}
slots := make([]slot, len(providers))
var wg sync.WaitGroup
for i, provider := range providers {
wg.Go(func() {
res, err := upstream.Search(ctx, provider, query)
if err != nil {
slots[i] = slot{
failed: true,
err: ProviderError{
Provider: provider.Provider(),
Err: err,
},
}
return
}
if res.Provider == catalog.ProviderUnknown {
res.Provider = provider.Provider()
}
slots[i] = slot{ok: true, res: res}
})
}
wg.Wait()
results := make([]upstream.SearchResponse, 0, len(providers))
providerErrors := make([]ProviderError, 0)
for _, item := range slots {
if item.ok {
results = append(results, item.res)
}
if item.failed {
providerErrors = append(providerErrors, item.err)
}
}
return results, providerErrors
}
// FetchMany executes fetch on all providers in parallel and returns all
// successful results.
func (r *Router) FetchMany(
ctx context.Context,
local upstream.LocalContext,
providers []upstream.PackageProvider,
id catalog.VersionedRef,
) ([]upstream.ResolvedPackage, []ProviderError) {
if len(providers) == 0 {
return nil, nil
}
type slot struct {
res upstream.ResolvedPackage
err ProviderError
ok bool
failed bool
}
slots := make([]slot, len(providers))
var wg sync.WaitGroup
for i, provider := range providers {
wg.Go(func() {
resolvedID, err := provider.ResolveVersionSelector(ctx, local, id)
if err != nil {
slots[i] = slot{
failed: true,
err: ProviderError{
Provider: provider.Provider(),
Err: err,
},
}
return
}
remoteData, err := provider.Fetch(ctx, local, resolvedID)
if err != nil {
slots[i] = slot{
failed: true,
err: ProviderError{
Provider: provider.Provider(),
Err: err,
},
}
return
}
// Routing binds the provider's concrete identity to the selected
// ecosystem/name/version coordinate returned by resolution.
remoteData.ID = catalog.VersionedRef{
Name: resolvedID.Name,
Provider: provider.Provider(),
Ecosystem: resolvedID.Ecosystem,
Version: resolvedID.Version,
}
slots[i] = slot{ok: true, res: remoteData}
})
}
wg.Wait()
results := make([]upstream.ResolvedPackage, 0, len(providers))
providerErrors := make([]ProviderError, 0)
for _, item := range slots {
if item.ok {
results = append(results, item.res)
}
if item.failed {
providerErrors = append(providerErrors, item.err)
}
}
return results, providerErrors
}
// GetInfoFirstSuccess executes info on all providers in parallel and returns the
// first successful result. Ref.Provider records the provider that answered.
func (r *Router) GetInfoFirstSuccess(
ctx context.Context,
providers []upstream.InfoProvider,
ref catalog.Ref,
) (upstream.Info, []ProviderError, error) {
if len(providers) == 0 {
return upstream.Info{}, nil, ErrNoProviderSucceeded
}
if err := ctx.Err(); err != nil {
return upstream.Info{}, nil, err
}
resChan := make(chan upstream.Info, len(providers))
errChan := make(chan ProviderError, len(providers))
for _, provider := range providers {
go func(provider upstream.InfoProvider) {
metadata, err := provider.Info(ctx, ref)
if err != nil {
errChan <- ProviderError{Provider: provider.Provider(), Err: fmt.Errorf("information failed: %w", err)}
return
}
resChan <- upstream.Info{
Ref: catalog.Ref{Name: ref.Name, Provider: provider.Provider()},
Metadata: metadata,
}
}(provider)
}
providerErrors := make([]ProviderError, 0, len(providers))
pending := len(providers)
for pending > 0 {
select {
case result := <-resChan:
return result, providerErrors, nil
case providerErr := <-errChan:
providerErrors = append(providerErrors, providerErr)
pending--
case <-ctx.Done():
return upstream.Info{}, providerErrors, ctx.Err()
}
}
return upstream.Info{}, providerErrors, joinProviderErrors(providerErrors)
}
// ArtifactLookup is the result of a hash lookup on the upstream mappers.
// Ref is zero when no mapper matched the artifact. Errors holds the failure
// of each mapper. A lookup with errors is incomplete, not a miss.
type ArtifactLookup struct {
Ref catalog.VersionedRef
Errors []ProviderError
}
// Matched reports whether some mapper identified the artifact.
func (l ArtifactLookup) Matched() bool { return l.Ref.Name != "" }
// Complete reports whether every attempted mapper finished without failure.
func (l ArtifactLookup) Complete() bool { return len(l.Errors) == 0 }
// ResolveArtifactByHash resolves an artifact's canonical upstream reference.
// Mappers run in policy order; a failed or missing match does not stop later
// mappers. Provider failures are returned for callers that surface warnings.
func (r *Router) ResolveArtifactByHash(
ctx context.Context,
artifact upstream.ArtifactFingerprint,
) ArtifactLookup {
result := ArtifactLookup{}
for _, mapper := range r.artifactMappers() {
ref, _, ok, err := mapper.PackageByHash(ctx, artifact)
if err != nil {
result.Errors = append(result.Errors, ProviderError{
Provider: mapper.Provider(),
Err: err,
})
continue
}
if ok && ref.Name != "" {
result.Ref = ref
return result
}
}
return result
}
// DependenciesMany executes Dependencies on all providers in parallel and
// returns all successful results. An error is returned only when every provider
// fails; partial failures are collected in the returned []ProviderError slice.
func (r *Router) DependenciesMany(
ctx context.Context,
local upstream.LocalContext,
providers []upstream.PackageProvider,
id catalog.VersionedRef,
) ([]upstream.DependencySet, []ProviderError) {
if len(providers) == 0 {
return nil, nil
}
type slot struct {
res upstream.DependencySet
err ProviderError
ok bool
failed bool
}
slots := make([]slot, len(providers))
var wg sync.WaitGroup
for i, provider := range providers {
wg.Go(func() {
deps, err := provider.Dependencies(ctx, local, id)
if err != nil {
slots[i] = slot{
failed: true,
err: ProviderError{
Provider: provider.Provider(),
Err: err,
},
}
return
}
result := upstream.DependencySet{}
if deps != nil {
result = *deps
}
slots[i] = slot{ok: true, res: result}
})
}
wg.Wait()
results := make([]upstream.DependencySet, 0, len(providers))
providerErrors := make([]ProviderError, 0)
for _, item := range slots {
if item.ok {
results = append(results, item.res)
}
if item.failed {
providerErrors = append(providerErrors, item.err)
}
}
if len(results) == 0 && len(providerErrors) > 0 {
return nil, providerErrors
}
return results, providerErrors
}
func joinProviderErrors(providerErrors []ProviderError) error {
if len(providerErrors) == 0 {
return ErrNoProviderSucceeded
}
joined := make([]error, 0, len(providerErrors))
for _, providerErr := range providerErrors {
joined = append(joined, providerErr)
}
return errors.Join(joined...)
}
package routing
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json/v2"
"fmt"
"path"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/upstream/providers/mojang"
versionpkg "github.com/mclucy/lucy/version"
)
type mavenLibrary struct {
Name string `json:"name"`
URL string `json:"url"`
Downloads struct {
Artifact *struct {
Path string `json:"path"`
URL string `json:"url"`
SHA1 string `json:"sha1"`
} `json:"artifact"`
} `json:"downloads"`
}
// libraryInput turns one profile library into a bootstrap input. The second
// result is false when the profile explicitly names a library that cannot be
// downloaded: Forge declares the client classifier with an empty artifact URL
// because the jar exists nowhere in a public Maven repository.
func libraryInput(lib mavenLibrary, z *zip.Reader, version versionpkg.Version) (server.BootstrapInput, bool, error) {
download := lib.Downloads.Artifact
p := ""
if download != nil {
p = download.Path
}
if p == "" {
var err error
p, err = MavenPath(lib.Name)
if err != nil {
return server.BootstrapInput{}, false, err
}
}
a := catalog.Artifact{Provider: catalog.ProviderMaven, ProjectID: lib.Name, Version: version, Filename: path.Base(p), Hashes: map[string]string{}}
if z != nil {
if raw, err := zipBytes(z, "maven/"+p); err != nil {
return server.BootstrapInput{}, false, err
} else if raw != nil {
a.Embedded = "maven/" + p
sum := sha256.Sum256(raw)
a.Hashes["sha256"] = hex.EncodeToString(sum[:])
return server.BootstrapInput{Path: "libraries/" + p, Artifact: a}, true, nil
}
}
if download != nil && download.URL == "" {
return server.BootstrapInput{}, false, nil
}
if download != nil {
a.URL = download.URL
}
if a.URL == "" {
base := lib.URL
if base == "" {
base = "https://libraries.minecraft.net/"
}
a.URL = strings.TrimRight(base, "/") + "/" + p
}
if download != nil && download.SHA1 != "" {
a.Hashes["sha1"] = download.SHA1
}
return server.BootstrapInput{Path: "libraries/" + p, Artifact: a}, true, nil
}
func MavenPath(coordinate string) (string, error) {
base, extension, has := strings.Cut(coordinate, "@")
if !has {
extension = "jar"
}
parts := strings.Split(base, ":")
if len(parts) < 3 || len(parts) > 4 {
return "", fmt.Errorf("invalid Maven coordinate %q", coordinate)
}
name := parts[1] + "-" + parts[2]
if len(parts) == 4 {
name += "-" + parts[3]
}
return path.Join(strings.ReplaceAll(parts[0], ".", "/"), parts[1], parts[2], name+"."+extension), nil
}
func zipBytes(z *zip.Reader, name string) ([]byte, error) {
raw, _, err := fsutil.ReadZipEntry(
z,
name,
fsutil.MaxNestedZipEntryBytes,
)
return raw, err
}
func (r *Router) resolveInstaller(ctx context.Context, req ServerProductRequest, out *ServerProductResolution, installer string) error {
z, err := zip.OpenReader(installer)
if err != nil {
return err
}
defer z.Close()
raw, err := zipBytes(&z.Reader, "install_profile.json")
if err != nil {
return err
}
if raw == nil {
return fmt.Errorf("installer has no install_profile.json")
}
var profile struct {
Minecraft string `json:"minecraft"`
Version string `json:"version"`
JSON string `json:"json"`
Libraries []mavenLibrary `json:"libraries"`
}
if err := json.Unmarshal(raw, &profile); err != nil {
return err
}
if profile.Minecraft != req.Minecraft.String() {
return fmt.Errorf("%s installer targets Minecraft %s, requested %s", req.Product, profile.Minecraft, req.Minecraft)
}
out.ProductVersion = req.ProductVersion
if floating(out.ProductVersion) {
out.ProductVersion = versionpkg.Version(strings.TrimSuffix(strings.TrimPrefix(out.Artifact.Filename, req.Product.String()+"-"), "-installer.jar"))
if req.Product == server.ProductForge {
out.ProductVersion = versionpkg.Version(strings.TrimPrefix(out.ProductVersion.String(), req.Minecraft.String()+"-"))
}
}
out.Artifact.Version = out.ProductVersion
out.Bootstrapper = "forge-installer"
if profile.JSON != "" {
raw, e := zipBytes(&z.Reader, strings.TrimPrefix(profile.JSON, "/"))
if e != nil {
return e
}
var detail struct {
Libraries []mavenLibrary `json:"libraries"`
}
if e := json.Unmarshal(raw, &detail); e != nil {
return e
}
profile.Libraries = append(profile.Libraries, detail.Libraries...)
}
seen := map[string]bool{}
for _, lib := range profile.Libraries {
input, downloadable, e := libraryInput(lib, &z.Reader, out.ProductVersion)
if e != nil {
return e
}
if !downloadable {
r.logger.Debug(
"routing: installer library skipped",
"library", lib.Name,
"reason", "no downloadable artifact",
)
continue
}
if seen[input.Path] {
r.logger.Debug(
"routing: installer library skipped",
"library", lib.Name,
"path", input.Path,
"reason", "duplicate library path",
)
continue
}
seen[input.Path] = true
out.Inputs = append(out.Inputs, input)
}
// Processors may need Mojang server mappings in addition to the server JAR.
var index struct {
Versions []struct {
ID string `json:"id"`
URL string `json:"url"`
} `json:"versions"`
}
if err := r.Metadata(ctx, mojang.VersionManifestURL, &index); err != nil {
return err
}
for _, v := range index.Versions {
if v.ID == req.Minecraft.String() {
var detail struct {
Downloads map[string]struct {
URL string `json:"url"`
SHA1 string `json:"sha1"`
} `json:"downloads"`
}
if err := r.Metadata(ctx, v.URL, &detail); err != nil {
return err
}
if mapping, ok := detail.Downloads["server_mappings"]; ok {
out.Inputs = append(out.Inputs, server.BootstrapInput{Path: "server-mappings.txt", Provider: catalog.ProviderMojang, ProjectID: "server-mappings", Version: req.Minecraft, Filename: "server-mappings.txt", URL: mapping.URL, Hashes: map[string]string{"sha1": mapping.SHA1}})
}
break
}
}
r.logger.Debug(
"routing: installer inputs resolved",
"product", req.Product,
"version", out.ProductVersion,
"inputs", len(out.Inputs),
)
return nil
}
package routing
import (
"context"
"fmt"
"strings"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/version"
)
const (
paperProjectURL = "https://fill.papermc.io/v3/projects/paper"
purpurProjectURL = "https://api.purpurmc.org/v2/purpur"
)
// LatestMinecraftVersion reports the newest Minecraft version a server product
// serves. It answers a floating Minecraft request: each product names the
// version it builds for, which can lag Mojang's release channel.
func (r *Router) LatestMinecraftVersion(
ctx context.Context,
product server.Product,
) (version.Version, error) {
switch product {
case server.ProductMinecraft:
return r.LatestMojangMinecraft(ctx, false)
case server.ProductFabric:
return r.fabric.LatestGameVersion(ctx)
case server.ProductForge:
return r.forge.LatestMinecraftVersion(ctx)
case server.ProductNeoForge:
return r.neoforge.LatestMinecraftVersion(ctx)
case server.ProductPaper:
return r.latestPaperMinecraft(ctx)
case server.ProductPurpur:
return r.latestPurpurMinecraft(ctx)
default:
return "", fmt.Errorf(
"server product %q publishes no Minecraft versions",
product,
)
}
}
// LatestMojangMinecraft reports the newest Minecraft version on Mojang's
// release channel, or on the snapshot channel when snapshot is set.
func (r *Router) LatestMojangMinecraft(
ctx context.Context,
snapshot bool,
) (version.Version, error) {
manifest, err := r.mojang.VersionManifest(ctx)
if err != nil {
return "", err
}
if snapshot {
return version.Version(manifest.Latest.Snapshot), nil
}
return version.Version(manifest.Latest.Release), nil
}
// MinecraftReleases reports Mojang's release-channel versions, newest first.
func (r *Router) MinecraftReleases(
ctx context.Context,
) ([]version.Version, error) {
manifest, err := r.mojang.VersionManifest(ctx)
if err != nil {
return nil, err
}
releases := make([]version.Version, 0, len(manifest.Versions))
for _, entry := range manifest.Versions {
if entry.Type != "release" {
continue
}
releases = append(releases, version.Version(entry.ID))
}
return releases, nil
}
// latestPaperMinecraft reports the newest release Paper builds. The project
// endpoint groups versions by major line and lists prereleases beside them.
func (r *Router) latestPaperMinecraft(ctx context.Context) (version.Version, error) {
var project struct {
Versions map[string][]string `json:"versions"`
}
if err := r.Metadata(ctx, paperProjectURL, &project); err != nil {
return "", err
}
versions := make([]version.Version, 0, len(project.Versions))
for _, group := range project.Versions {
for _, id := range group {
if isMinecraftRelease(id) {
versions = append(versions, version.Version(id))
}
}
}
return version.NewestMinecraft(versions)
}
func (r *Router) latestPurpurMinecraft(
ctx context.Context,
) (version.Version, error) {
var project struct {
Versions []string `json:"versions"`
}
if err := r.Metadata(ctx, purpurProjectURL, &project); err != nil {
return "", err
}
versions := make([]version.Version, 0, len(project.Versions))
for _, id := range project.Versions {
if isMinecraftRelease(id) {
versions = append(versions, version.Version(id))
}
}
return version.NewestMinecraft(versions)
}
// isMinecraftRelease rejects snapshots, prereleases, and release candidates,
// which carry a suffix the release grammar does not accept.
func isMinecraftRelease(id string) bool {
return !strings.Contains(id, "-")
}
package routing
import (
"archive/zip"
"cmp"
"context"
"fmt"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/server"
"github.com/mclucy/lucy/version"
)
// paperBuild is one build entry from the PaperMC fill API.
type paperBuild struct {
ID int `json:"id"`
Channel string `json:"channel"`
Downloads map[string]struct {
Name string `json:"name"`
URL string `json:"url"`
Checksums map[string]string `json:"checksums"`
} `json:"downloads"`
}
// IdentifyFillBuild matches a server jar digest against the published builds
// of a PaperMC fill project. Init uses it when a renamed jar no longer carries
// its build number.
func (r *Router) IdentifyFillBuild(
ctx context.Context,
project string,
minecraft version.Version,
sha256 string,
) (version.Version, bool, error) {
var builds []paperBuild
endpoint := "https://fill.papermc.io/v3/projects/" + project +
"/versions/" + minecraft.String() + "/builds"
if err := r.Metadata(ctx, endpoint, &builds); err != nil {
return "", false, err
}
for _, build := range builds {
asset, ok := build.Downloads["server:default"]
if !ok {
continue
}
if strings.EqualFold(asset.Checksums["sha256"], sha256) {
return version.Version(strconv.Itoa(build.ID)), true, nil
}
}
return "", false, nil
}
func (r *Router) resolvePaper(
ctx context.Context,
req ServerProductRequest,
out *ServerProductResolution,
) error {
var builds []paperBuild
endpoint := "https://fill.papermc.io/v3/projects/paper/versions/" + req.Minecraft.String() + "/builds"
if err := r.Metadata(ctx, endpoint, &builds); err != nil {
return err
}
slices.SortFunc(builds, func(a, b paperBuild) int { return cmp.Compare(b.ID, a.ID) })
for _, build := range builds {
v := strconv.Itoa(build.ID)
reason := ""
switch {
case !floating(req.ProductVersion) && v != req.ProductVersion.String():
reason = "build version mismatch"
case req.ProductVersion == version.Stable && build.Channel != "STABLE":
reason = "unstable build channel"
}
asset, ok := build.Downloads["server:default"]
if !ok && reason == "" {
reason = "no server download"
}
if reason != "" {
r.logger.Debug(
"routing: paper build skipped",
"minecraft", req.Minecraft,
"build", v,
"reason", reason,
)
continue
}
out.ProductVersion, out.Bootstrapper = version.Version(v), "paperclip"
out.Artifact = catalog.Artifact{
Provider: catalog.ProviderPaperMC,
ProjectID: "paper",
ReleaseID: req.Minecraft.String() + "/" + v,
FileID: "server:default",
Version: version.Version(v),
Filename: asset.Name,
URL: asset.URL,
Hashes: asset.Checksums,
}
r.logger.Debug(
"routing: paper build selected",
"minecraft", req.Minecraft,
"build", v,
"channel", build.Channel,
"requested", req.ProductVersion,
)
return nil
}
return fmt.Errorf("no Paper build %s for Minecraft %s", req.ProductVersion, req.Minecraft)
}
func (r *Router) resolveGithubProduct(
ctx context.Context,
req ServerProductRequest,
out *ServerProductResolution,
acquire func(*catalog.Artifact) (artifact.MaterializedFile, error),
) error {
var repo struct {
ID int64 `json:"id"`
}
if err := r.Metadata(ctx, "https://api.github.com/repos/"+req.Repository, &repo); err != nil {
return err
}
var releases []struct {
ID int64 `json:"id"`
Tag string `json:"tag_name"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
Assets []struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string `json:"browser_download_url"`
Digest string `json:"digest"`
} `json:"assets"`
}
if err := r.Metadata(ctx, "https://api.github.com/repos/"+req.Repository+"/releases?per_page=100", &releases); err != nil {
return err
}
for _, rl := range releases {
reason := ""
switch {
case rl.Draft:
reason = "draft release"
case !floating(req.ProductVersion) && rl.Tag != req.ProductVersion.String():
reason = "release tag mismatch"
case req.ProductVersion == version.Stable && rl.Prerelease:
reason = "prerelease excluded"
}
if reason != "" {
r.logger.Debug(
"routing: github release skipped",
"repository", req.Repository,
"release", rl.Tag,
"reason", reason,
)
continue
}
for _, a := range rl.Assets {
reason := ""
if !strings.HasSuffix(a.Name, ".jar") {
reason = "not a jar asset"
} else if req.FilePattern != "" {
matched, e := filepath.Match(req.FilePattern, a.Name)
if e != nil {
return e
}
if !matched {
reason = "file pattern mismatch"
}
}
if reason != "" {
r.logger.Debug(
"routing: github release asset skipped",
"repository", req.Repository,
"release", rl.Tag,
"asset", a.Name,
"reason", reason,
)
continue
}
hashes := map[string]string{}
if h, ok := strings.CutPrefix(a.Digest, "sha256:"); ok {
hashes["sha256"] = h
}
candidate := catalog.Artifact{
Provider: catalog.ProviderGitHub,
ProjectID: strconv.FormatInt(repo.ID, 10),
ReleaseID: strconv.FormatInt(rl.ID, 10),
FileID: strconv.FormatInt(a.ID, 10),
Version: version.Version(rl.Tag),
Filename: a.Name,
URL: a.URL,
Hashes: hashes,
}
trial := *out
trial.ProductVersion = version.Version(rl.Tag)
trial.Bootstrapper = "paperclip"
materialized, e := acquire(&candidate)
if e != nil {
return e
}
candidate.Hashes = materialized.Hashes
trial.Artifact = candidate
if e := paperclipInput(&trial, materialized.Path); e != nil {
r.logger.Debug(
"routing: github fork candidate rejected",
"repository", req.Repository,
"release", rl.Tag,
"asset", a.Name,
"reason", "paperclip download context unusable",
)
continue
}
*out = trial
r.logger.Debug(
"routing: github fork core selected",
"repository", req.Repository,
"release", rl.Tag,
"asset", a.Name,
)
return nil
}
}
return fmt.Errorf("no GitHub Paper fork artifact for Minecraft %s in %s", req.Minecraft, req.Repository)
}
func paperclipInput(out *ServerProductResolution, corePath string) error {
z, err := zip.OpenReader(corePath)
if err != nil {
return err
}
defer z.Close()
data, err := zipBytes(&z.Reader, "META-INF/download-context")
if err != nil {
return err
}
if data == nil {
return fmt.Errorf("paper fork has no supported Paperclip download context")
}
fields := strings.Fields(string(data))
if len(fields) != 3 {
return fmt.Errorf("invalid Paperclip download context")
}
expected := "mojang_" + out.Minecraft.String() + ".jar"
if fields[2] != expected {
return fmt.Errorf("core targets %s, requested Minecraft %s", fields[2], out.Minecraft)
}
out.Inputs = []server.BootstrapInput{
{
Path: path.Join("cache", fields[2]),
Provider: catalog.ProviderMojang,
ProjectID: "minecraft",
Version: out.Minecraft,
Filename: fields[2],
URL: fields[1],
Hashes: map[string]string{"sha256": fields[0]},
},
}
return nil
}
package routing
import "github.com/mclucy/lucy/catalog"
var searchProvidersInPriorityOrder = []catalog.Provider{
catalog.ProviderModrinth,
catalog.ProviderCurseForge,
catalog.ProviderHangar,
catalog.ProviderSpiget,
}
// autoProviders is the default candidate list for an unspecified platform.
// curseforge reports whether the configured provider instances include it.
func autoProviders(curseforge bool) []catalog.Provider {
return append(modProviders(curseforge), catalog.ProviderMCDR)
}
func modProviders(curseforge bool) []catalog.Provider {
providers := []catalog.Provider{catalog.ProviderModrinth}
if curseforge {
providers = append(providers, catalog.ProviderCurseForge)
}
return providers
}
func providersForEcosystem(
ecosystem catalog.Ecosystem,
curseforge bool,
) ([]catalog.Provider, error) {
switch ecosystem {
case catalog.EcosystemUnspecified:
return autoProviders(curseforge), nil
case catalog.EcosystemMCDR:
return []catalog.Provider{catalog.ProviderMCDR}, nil
case catalog.EcosystemForge, catalog.EcosystemFabric, catalog.EcosystemNeoForge,
catalog.EcosystemBukkit, catalog.EcosystemPaper:
return searchProvidersForEcosystem(ecosystem, curseforge), nil
default:
return nil, ErrInvalidEcosystem
}
}
func searchProvidersForEcosystem(
ecosystem catalog.Ecosystem,
curseforge bool,
) []catalog.Provider {
providers := make(
[]catalog.Provider,
0,
len(searchProvidersInPriorityOrder),
)
for _, provider := range searchProvidersInPriorityOrder {
if provider == catalog.ProviderCurseForge && !curseforge {
continue
}
support, ok := EcosystemSupportedBy(provider, ecosystem)
if !ok || !support.Supported {
continue
}
providers = append(providers, provider)
}
return providers
}
type providerSelection struct {
providers []catalog.Provider
fallback bool
empty bool
}
func providersFromEcosystems(
ecosystems []catalog.Ecosystem,
curseforge bool,
) providerSelection {
selection := providerSelection{}
seen := map[catalog.Provider]struct{}{}
sawKnownCapability := false
sawProxyCapability := false
appendProvider := func(provider catalog.Provider) {
if _, ok := seen[provider]; ok {
return
}
seen[provider] = struct{}{}
selection.providers = append(selection.providers, provider)
}
for _, ecosystem := range ecosystems {
switch ecosystem {
case catalog.EcosystemFabric,
catalog.EcosystemForge,
catalog.EcosystemNeoForge:
sawKnownCapability = true
appendProvider(catalog.ProviderModrinth)
if curseforge {
appendProvider(catalog.ProviderCurseForge)
}
case catalog.EcosystemBukkit,
catalog.EcosystemPaper:
sawKnownCapability = true
appendProvider(catalog.ProviderModrinth)
appendProvider(catalog.ProviderHangar)
appendProvider(catalog.ProviderSpiget)
case catalog.EcosystemMCDR:
sawKnownCapability = true
appendProvider(catalog.ProviderMCDR)
case catalog.EcosystemBungeeCord, catalog.EcosystemVelocity:
sawKnownCapability = true
sawProxyCapability = true
}
}
if len(selection.providers) > 0 {
return selection
}
if sawProxyCapability {
selection.empty = true
return selection
}
if !sawKnownCapability {
selection.fallback = true
}
selection.empty = true
return selection
}
package routing
import (
"context"
"fmt"
"strings"
"github.com/mclucy/lucy/version"
)
type purpurVersionInfo struct {
Builds struct {
Latest string `json:"latest"`
All []string `json:"all"`
} `json:"builds"`
}
type purpurBuildInfo struct {
Build string `json:"build"`
Version string `json:"version"`
MD5 string `json:"md5"`
}
// IdentifyPurpurBuild matches a Purpur jar digest against the published builds
// for a Minecraft version. The Purpur API exposes only MD5 digests, so init
// walks builds from newest to oldest until one matches.
func (r *Router) IdentifyPurpurBuild(
ctx context.Context,
minecraft version.Version,
md5 string,
) (version.Version, bool, error) {
base := "https://api.purpurmc.org/v2/purpur/" + minecraft.String()
var listed purpurVersionInfo
if err := r.Metadata(ctx, base, &listed); err != nil {
return "", false, err
}
builds := listed.Builds.All
if len(builds) == 0 && listed.Builds.Latest != "" {
builds = []string{listed.Builds.Latest}
}
for index := len(builds) - 1; index >= 0; index-- {
build := builds[index]
var detail purpurBuildInfo
if err := r.Metadata(ctx, base+"/"+build, &detail); err != nil {
return "", false, fmt.Errorf("Purpur build %s: %w", build, err)
}
if detail.Version != "" && detail.Version != minecraft.String() {
continue
}
if strings.EqualFold(detail.MD5, md5) {
if detail.Build != "" {
build = detail.Build
}
return version.Version(build), true, nil
}
}
return "", false, nil
}
package routing
import (
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
)
type SearchAggregateOptions struct {
Enabled bool
}
// MaybeAggregateSearchResults is an optional post-processing utility. It is
// disabled by default and intentionally decoupled from SearchMany.
func MaybeAggregateSearchResults(
results []upstream.SearchResponse,
options SearchAggregateOptions,
) []upstream.SearchResponse {
if !options.Enabled || len(results) <= 1 {
return results
}
return []upstream.SearchResponse{AggregateSearchResults(results)}
}
// AggregateSearchResults merges multi-provider search results into one result.
// Provider metadata remains in the original non-aggregated form and should be
// preferred unless aggregation is explicitly required by callers.
func AggregateSearchResults(results []upstream.SearchResponse) upstream.SearchResponse {
aggregated := upstream.SearchResponse{
Provider: catalog.ProviderUnspecified,
Items: make([]upstream.SearchResult, 0),
}
for _, res := range results {
aggregated.Items = append(aggregated.Items, res.Items...)
}
return aggregated
}
// Package upstream defines the core upstream abstraction layer.
//
// catalog.Provider identifies the execution layer that fetches metadata or
// artifacts; catalog.Registry identifies a fully functional package repository
// and is executed by exactly one provider. Provider capabilities implement the
// small interfaces in this package. Provider selection, unspecified-provider
// policy, and multi-provider execution strategies live outside this package in
// upstream/routing.
//
// Dependency inversion: this package defines the capability interfaces and
// normalized conversion contracts. Concrete providers implement those small
// interfaces and depend on these contracts, not the other way around.
// Callers pass capability interfaces into Search/Info.
package upstream
import (
"context"
"fmt"
)
func Search(
ctx context.Context,
searcher Searcher,
query Query,
) (res SearchResponse, err error) {
res, err = searcher.Search(ctx, query)
if err != nil {
return res, err
}
if len(res.Items) == 0 {
return res, fmt.Errorf("no projects found for \"%s\"", query.Keyword)
}
return res, nil
}
package upstream
import (
"strings"
"unicode"
)
// LooksLikeMarkdown reports whether text carries Markdown-only structure:
// ATX headings, fenced code, list items, blockquotes, or inline code, links,
// and emphasis. Plain prose, including bare URLs, is not Markdown.
//
// Detection is purely lexical: upstream metadata normalization must not depend
// on a presentation renderer.
func LooksLikeMarkdown(text string) bool {
for line := range strings.Lines(text) {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "```") ||
strings.HasPrefix(trimmed, "~~~") {
return true
}
if isATXHeading(trimmed) ||
isListItem(trimmed) ||
strings.HasPrefix(trimmed, "> ") ||
trimmed == ">" {
return true
}
if hasInlineMarkdown(trimmed) {
return true
}
}
return false
}
func isATXHeading(line string) bool {
level := 0
for level < len(line) && line[level] == '#' {
level++
}
if level == 0 || level > 6 {
return false
}
return level == len(line) || line[level] == ' ' || line[level] == '\t'
}
func isListItem(line string) bool {
for _, marker := range []string{"- ", "* ", "+ "} {
if strings.HasPrefix(line, marker) {
return true
}
}
digits := 0
for digits < len(line) && unicode.IsDigit(rune(line[digits])) {
digits++
}
return digits > 0 &&
digits+1 < len(line) &&
(line[digits] == '.' || line[digits] == ')') &&
line[digits+1] == ' '
}
func hasInlineMarkdown(line string) bool {
if strings.Contains(line, "`") {
return true
}
if strings.Contains(line, "](http://") || strings.Contains(line, "](https://") {
return true
}
if strings.Contains(line, "**") || strings.Contains(line, "__") {
return true
}
// A single asterisk pair around non-space text is emphasis; a lone
// asterisk is ordinary punctuation.
if first := strings.IndexByte(line, '*'); first >= 0 {
rest := line[first+1:]
if second := strings.IndexByte(rest, '*'); second > 0 {
inner := rest[:second]
if strings.TrimSpace(inner) == inner && inner != "" {
return true
}
}
}
return false
}
package upstream
import "strings"
// ReleaseChannel is the stability channel published by a provider.
type ReleaseChannel string
const (
ReleaseChannelUnknown ReleaseChannel = ""
ReleaseChannelRelease ReleaseChannel = "release"
ReleaseChannelBeta ReleaseChannel = "beta"
ReleaseChannelAlpha ReleaseChannel = "alpha"
)
func ParseReleaseChannel(s string) ReleaseChannel {
switch strings.ToLower(strings.TrimSpace(s)) {
case "release", "stable":
return ReleaseChannelRelease
case "beta", "prerelease", "pre-release":
return ReleaseChannelBeta
case "alpha":
return ReleaseChannelAlpha
default:
return ReleaseChannelUnknown
}
}
package upstream
import (
"context"
"crypto/sha1"
"strings"
"time"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/version"
)
// ProviderIdentifier exposes the execution-layer identity of a provider
// capability.
type ProviderIdentifier interface {
Provider() catalog.Provider
}
// LocalContext is an immutable snapshot of local runtime facts that can affect
// upstream resolution. Its zero value describes an unknown local runtime.
//
// The application layer derives LocalContext from workspace discovery. Upstream
// must not depend on that discovery mechanism or its filesystem representation,
// and providers must not prompt for missing facts: interactive input belongs to
// the command boundary.
type LocalContext struct {
Ecosystem catalog.Ecosystem
MinecraftVersion version.Version
MCDRVersion version.Version
}
// HasMinecraftVersion reports whether the local runtime provides a concrete
// Minecraft version suitable for upstream compatibility filtering.
func (c LocalContext) HasMinecraftVersion() bool {
return c.MinecraftVersion != "" &&
!c.MinecraftVersion.IsInvalid() &&
!c.MinecraftVersion.CanInfer()
}
type Fetcher interface {
Fetch(ctx context.Context, local LocalContext, id catalog.VersionedRef) (
ResolvedPackage,
error,
)
}
type DependencyResolver interface {
Dependencies(
ctx context.Context,
local LocalContext,
id catalog.VersionedRef,
) (*DependencySet, error)
}
type PackageResolver interface {
ProviderIdentifier
VersionSelectorResolver
Fetcher
}
type PackageProvider interface {
PackageResolver
DependencyResolver
}
type SearchProvider interface {
ProviderIdentifier
Searcher
}
type InfoProvider interface {
ProviderIdentifier
Informer
}
type ArtifactMapProvider interface {
ProviderIdentifier
ArtifactMapper
}
// InstallerProvider installs a server product for one ecosystem.
type InstallerProvider interface {
ProviderIdentifier
VersionSelectorResolver
Fetcher
}
type ArtifactMapper interface {
PackageByHash(
ctx context.Context,
artifact ArtifactFingerprint,
) (
ref catalog.VersionedRef,
hash string,
ok bool,
err error,
)
}
type ArtifactFingerprint interface {
SHA1() ([sha1.Size]byte, error)
MurmurHash() (uint32, error)
}
type VersionSelectorResolver interface {
ResolveVersionSelector(
ctx context.Context,
local LocalContext,
ref catalog.VersionedRef,
) (
resolved catalog.VersionedRef,
err error,
)
}
type Searcher interface {
Search(ctx context.Context, q Query) (resp SearchResponse, err error)
}
type Query struct {
Keyword string
IncludeClient bool
FilterEcosystem catalog.Ecosystem
Tags []string
Limit int
}
type SearchResponse struct {
// Provider labels which upstream provider produced this result set.
Provider catalog.Provider `json:"provider"`
Items []SearchResult `json:"items"`
Warnings []error `json:"warnings,omitempty"`
}
type Informer interface {
Info(ctx context.Context, ref catalog.Ref) (info catalog.Metadata, err error)
}
// Info is a project description from one upstream. Ref identifies the
// upstream project that answered. Metadata is the content it returned.
type Info struct {
Ref catalog.Ref
Metadata catalog.Metadata
}
// SearchResult represents a single search result with optional metadata.
// Providers populate what their API returns; display layer handles zero values.
type SearchResult struct {
ProjectName string `json:"project"`
Provider catalog.Provider `json:"provider"`
// Optional metadata — zero value means unavailable.
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Downloads int64 `json:"downloads,omitempty"`
LastUpdated time.Time `json:"last_updated,omitzero"`
}
func (r SearchResult) DisplayName() string {
if r.Provider == catalog.ProviderMCDR {
return strings.ReplaceAll(r.ProjectName, "_", "-")
}
return r.ProjectName
}
package version
import (
"fmt"
"strings"
)
// ErrAmbiguousVersion is returned when attempting to parse a non-exact
// version constant.
var ErrAmbiguousVersion = fmt.Errorf("attempting to parse an ambiguous version")
// Engine parses, orders, and matches versions under one grammar.
type Engine struct {
kind Dialect
}
// NewEngine binds operations to a dialect identifier.
func NewEngine(kind Dialect) (Engine, error) {
if !kind.Valid() {
return Engine{}, fmt.Errorf("unsupported version dialect %q", kind)
}
return Engine{kind: kind}, nil
}
// ParseVersion parses a concrete version. It rejects selectors, versions
// containing wildcards, and input outside the dialect grammar.
func (d Engine) ParseVersion(raw Version) (ResolvableVersion, error) {
switch raw {
case Beta, Stable, None, Any, Unknown:
return nil, fmt.Errorf("%w: %s", ErrAmbiguousVersion, raw)
}
var parsed ResolvableVersion
switch d.kind {
case DialectFabric:
parsed = parseFabricVersion(raw)
case DialectMCDR:
parsed = parseMCDRVersion(string(raw), false)
case DialectMaven:
parsed = parseMavenVersion(raw)
case DialectMinecraft:
parsed = parseMinecraftRelease(raw)
if parsed == nil {
parsed = parseMinecraftSnapshot(raw)
}
case DialectOpaque:
parsed = parseOpaqueVersion(raw)
default:
return nil, fmt.Errorf("unsupported version dialect %q", d.kind)
}
if parsed == nil || !parsed.Validate() {
return nil, fmt.Errorf(
"invalid %s version %q",
d.kind,
raw,
)
}
return parsed, nil
}
// ParseConstraint parses a version constraint. A nil expression with a nil
// error means the constraint accepts every version. Invalid input returns an
// error, never an unconstrained expression.
func (d Engine) ParseConstraint(raw string) (Expr, error) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "*" {
return nil, nil
}
switch d.kind {
case DialectFabric:
return parseFabricConstraint(raw)
case DialectMCDR:
return parseMCDRConstraint(raw)
case DialectMaven:
return parseMavenRange(raw)
case DialectMinecraft:
return parseMinecraftConstraint(raw)
case DialectOpaque:
return parseOpaqueConstraint(raw)
default:
return nil, fmt.Errorf("unsupported version dialect %q", d.kind)
}
}
// ParseConstraints parses each string as an alternative. One unconstrained
// alternative makes the whole expression unconstrained.
func (d Engine) ParseConstraints(raws []string) (Expr, error) {
if len(raws) == 0 {
return nil, nil
}
merged := make(Expr, 0, len(raws))
for _, raw := range raws {
expr, err := d.ParseConstraint(raw)
if err != nil {
return nil, err
}
if len(expr) == 0 {
return nil, nil
}
merged = append(merged, expr...)
}
if len(merged) == 0 {
return nil, nil
}
return merged, nil
}
// Compare orders two versions of this dialect. Different dialects are never
// comparable.
func (d Engine) Compare(
left, right ResolvableVersion,
) (int, bool) {
if left == nil || right == nil {
return 0, false
}
if left.Dialect() != d.kind || right.Dialect() != d.kind {
return 0, false
}
if comparison, ok := left.Compare(right); ok {
return comparison, true
}
if comparison, ok := right.Compare(left); ok {
return -comparison, true
}
return 0, false
}
// Match reports whether a parsed version satisfies an expression of this
// dialect. A nil or empty expression accepts every version.
func (d Engine) Match(
expr Expr,
actual ResolvableVersion,
) bool {
if len(expr) == 0 {
return true
}
if actual == nil || actual.Dialect() != d.kind {
return false
}
for _, conjunction := range expr {
matched := true
for _, term := range conjunction {
if !d.matchTerm(term, actual) {
matched = false
break
}
}
if matched {
return true
}
}
return false
}
// MatchText parses both sides and reports whether the version satisfies the
// constraint.
func (d Engine) MatchText(
raw string,
actual Version,
) (bool, error) {
expr, err := d.ParseConstraint(raw)
if err != nil {
return false, err
}
if len(expr) == 0 {
return true, nil
}
parsed, err := d.ParseVersion(actual)
if err != nil {
return false, err
}
return d.Match(expr, parsed), nil
}
func (d Engine) matchTerm(
term SubExpr,
actual ResolvableVersion,
) bool {
if term.Operator == OpNever || term.Value == nil {
return false
}
if term.Value.Dialect() != d.kind {
return false
}
if d.kind == DialectFabric {
_, actualSemantic := actual.(*SemverVersion)
_, boundSemantic := term.Value.(*SemverVersion)
if !actualSemantic || !boundSemantic {
return matchOpaqueOperator(
term.Operator,
term.Inverted,
actual,
term.Value,
)
}
}
comparison, ok := d.Compare(actual, term.Value)
if !ok {
// An incomparable operand makes the parsed predicate false, so its
// complement is true.
return term.Inverted
}
switch term.Operator {
case OpEq:
return comparison == 0
case OpNeq:
return comparison != 0
case OpGt:
return comparison > 0
case OpGte:
return comparison >= 0
case OpLt:
return comparison < 0
case OpLte:
return comparison <= 0
default:
return false
}
}
// matchOpaqueOperator applies Fabric Loader's rule for versions outside its
// semantic grammar: an inclusive predicate requires exact string equality,
// while an exclusive predicate never matches. For a term produced by
// negation, the result is the complement of the original predicate.
func matchOpaqueOperator(
operator Operator,
inverted bool,
left, right ResolvableVersion,
) bool {
leftString, leftOK := left.(fmt.Stringer)
rightString, rightOK := right.(fmt.Stringer)
if !leftOK || !rightOK {
return false
}
equal := leftString.String() == rightString.String()
if inverted {
return !matchOpaqueRaw(operator.Inverse(), equal)
}
return matchOpaqueRaw(operator, equal)
}
func matchOpaqueRaw(operator Operator, equal bool) bool {
switch operator {
case OpEq, OpGte, OpLte:
return equal
default:
return false
}
}
// ParseVersion parses a concrete version under dialect.
func ParseVersion(
raw Version,
dialect Dialect,
) (ResolvableVersion, error) {
bound, err := NewEngine(dialect)
if err != nil {
return nil, err
}
return bound.ParseVersion(raw)
}
// ParseConstraint parses a constraint under dialect.
func ParseConstraint(
raw string,
dialect Dialect,
) (Expr, error) {
bound, err := NewEngine(dialect)
if err != nil {
return nil, err
}
return bound.ParseConstraint(raw)
}
// ParseConstraints parses alternative constraints under dialect.
func ParseConstraints(
raws []string,
dialect Dialect,
) (Expr, error) {
bound, err := NewEngine(dialect)
if err != nil {
return nil, err
}
return bound.ParseConstraints(raws)
}
// Compare orders two versions. Versions from different dialects are
// incomparable.
func Compare(left, right ResolvableVersion) (int, bool) {
if left == nil || right == nil {
return 0, false
}
bound, err := NewEngine(left.Dialect())
if err != nil || left.Dialect() != right.Dialect() {
return 0, false
}
return bound.Compare(left, right)
}
// Match reports whether actual satisfies raw under dialect.
func Match(
raw string,
actual Version,
dialect Dialect,
) (bool, error) {
bound, err := NewEngine(dialect)
if err != nil {
return false, err
}
return bound.MatchText(raw, actual)
}
package version
// Expr is a boolean expression in disjunctive normal form: the outer slice is
// OR, the inner slice is AND. A nil or empty outer slice means the expression
// imposes no constraint.
type Expr [][]SubExpr
type SubExpr struct {
Value ResolvableVersion
Operator Operator
// Inverted records that Operator is the logical complement of a parsed
// predicate. Dialects with partial ordering use it to keep the complement
// consistent with their own predicate rules.
Inverted bool
}
// Negate returns the logical negation of the expression in disjunctive normal
// form. Negating a disjunction produces a conjunction of negated clauses, so
// the result is distributed back into OR-of-AND form.
func (exps Expr) Negate() Expr {
if len(exps) == 0 {
return Expr{{SubExpr{Operator: OpNever}}}
}
result := Expr{{}}
for _, conjunction := range exps {
if len(conjunction) == 0 {
return Expr{{SubExpr{Operator: OpNever}}}
}
clause := make([]SubExpr, 0, len(conjunction))
trueClause := false
for _, term := range conjunction {
inverted, always := term.negated()
if always {
trueClause = true
break
}
clause = append(clause, inverted)
}
if trueClause {
continue
}
next := make(Expr, 0, len(result)*len(clause))
for _, product := range result {
for _, term := range clause {
distributed := make([]SubExpr, 0, len(product)+1)
distributed = append(distributed, product...)
distributed = append(distributed, term)
next = append(next, distributed)
}
}
result = next
}
if len(result) == 1 && len(result[0]) == 0 {
return nil
}
if len(result) == 0 {
return Expr{{SubExpr{Operator: OpNever}}}
}
return result
}
// negated returns the logical complement of one term. always reports a term
// that is already true, whose negation contributes nothing to a conjunction.
func (term SubExpr) negated() (inverted SubExpr, always bool) {
if term.Operator == OpNever || term.Value == nil {
return SubExpr{}, true
}
return SubExpr{
Value: term.Value,
Operator: term.Operator.Inverse(),
Inverted: !term.Inverted,
}, false
}
type Operator uint8
const (
OpEq Operator = iota
OpNeq
OpGt
OpGte
OpLt
OpLte
// OpNever is the canonical unsatisfiable term produced by negating an
// unconstrained expression.
OpNever
)
func (op Operator) String() string {
switch op {
case OpEq:
return "equal"
case OpNeq:
return "not equal"
case OpGt:
return "greater than"
case OpGte:
return "greater than or equal"
case OpLt:
return "less than"
case OpLte:
return "less than or equal"
case OpNever:
return "never"
default:
return "unknown"
}
}
func (op Operator) ToSign() string {
switch op {
case OpEq:
return "="
case OpNeq:
return "!="
case OpGt:
return ">"
case OpGte:
return ">="
case OpLt:
return "<"
case OpLte:
return "<="
case OpNever:
return "!"
default:
return "unknown"
}
}
func (op Operator) Inverse() Operator {
switch op {
case OpEq:
return OpNeq
case OpNeq:
return OpEq
case OpGt:
return OpLte
case OpGte:
return OpLt
case OpLt:
return OpGte
case OpLte:
return OpGt
default:
return op
}
}
package version
import (
"encoding/json/jsontext"
"strings"
)
// MavenVersion implements Maven-style version ordering for Forge/NeoForge
// mod metadata and Maven version ranges.
type MavenVersion struct {
original string
tokens []mavenToken
}
type mavenToken struct {
separator byte
value string
numeric bool
followedByDigit bool
}
func parseMavenVersion(raw Version) ResolvableVersion {
original := string(raw)
if original == "" || len(original) > 256 {
return nil
}
return &MavenVersion{
original: original,
tokens: tokenizeMavenVersion(original),
}
}
func (v *MavenVersion) Compare(other ResolvableVersion) (int, bool) {
if v == nil || other == nil {
return 0, false
}
var o *MavenVersion
switch other := other.(type) {
case *MavenVersion:
o = other
default:
return 0, false
}
if o == nil {
return 0, false
}
maxLen := max(len(o.tokens), len(v.tokens))
for i := range maxLen {
left := mavenTokenAt(v.tokens, i, mavenTokenSeparatorAt(o.tokens, i))
right := mavenTokenAt(o.tokens, i, mavenTokenSeparatorAt(v.tokens, i))
if cmp := compareMavenToken(left, right); cmp != 0 {
return cmp, true
}
}
return 0, true
}
func (v *MavenVersion) Validate() bool {
return v != nil && v.original != ""
}
func (v *MavenVersion) Dialect() Dialect {
return DialectMaven
}
func (v *MavenVersion) String() string {
if v == nil {
return ""
}
return v.original
}
// MarshalJSONTo renders the version in its original string form. All fields
// are unexported, which encoding/json/v2 refuses to marshal.
func (v *MavenVersion) MarshalJSONTo(enc *jsontext.Encoder) error {
return enc.WriteToken(jsontext.String(v.String()))
}
func tokenizeMavenVersion(raw string) []mavenToken {
tokens := make([]mavenToken, 0)
separator := byte('.')
var buf strings.Builder
previousClass := byte(0)
flush := func(followedByDigit bool) {
if buf.Len() == 0 {
return
}
value := buf.String()
tokens = append(
tokens, mavenToken{
separator: separator,
value: value,
numeric: isMavenNumeric(value),
followedByDigit: followedByDigit,
},
)
buf.Reset()
}
for i := 0; i < len(raw); i++ {
ch := raw[i]
if isMavenSeparator(ch) {
flush(false)
separator = ch
previousClass = 0
continue
}
class := mavenCharClass(ch)
if buf.Len() > 0 && previousClass != 0 && class != previousClass {
flush(class == '0')
separator = '-'
}
buf.WriteByte(ch)
previousClass = class
}
flush(false)
return trimMavenNullTokens(tokens)
}
func isMavenSeparator(ch byte) bool {
return ch == '.' || ch == '-'
}
func mavenCharClass(ch byte) byte {
if ch >= '0' && ch <= '9' {
return '0'
}
return 'a'
}
func isMavenNumeric(value string) bool {
if value == "" {
return false
}
for i := 0; i < len(value); i++ {
if value[i] < '0' || value[i] > '9' {
return false
}
}
return true
}
func trimMavenNullTokens(tokens []mavenToken) []mavenToken {
tokens = trimTrailingMavenNullTokens(tokens)
for i := len(tokens) - 1; i >= 0; i-- {
if tokens[i].separator != '-' {
continue
}
start := i
for start > 0 && isMavenNullToken(tokens[start-1]) {
start--
}
if start == i {
continue
}
tokens = append(tokens[:start], tokens[i:]...)
i = start
}
return trimTrailingMavenNullTokens(tokens)
}
func trimTrailingMavenNullTokens(tokens []mavenToken) []mavenToken {
for len(tokens) > 0 && isMavenNullToken(tokens[len(tokens)-1]) {
tokens = tokens[:len(tokens)-1]
}
return tokens
}
func isMavenNullToken(token mavenToken) bool {
if token.numeric {
return compareMavenInteger(token.value, "0") == 0
}
switch normalizeMavenQualifier(token.value) {
case "", "final", "ga", "release":
return true
default:
return false
}
}
func mavenTokenAt(
tokens []mavenToken,
index int,
otherSeparator byte,
) mavenToken {
if index < len(tokens) {
return tokens[index]
}
if otherSeparator == 0 {
otherSeparator = '.'
}
if otherSeparator == '.' {
return mavenToken{separator: otherSeparator, value: "0", numeric: true}
}
return mavenToken{separator: otherSeparator, value: "", numeric: false}
}
func mavenTokenSeparatorAt(tokens []mavenToken, index int) byte {
if index < len(tokens) {
return tokens[index].separator
}
return 0
}
func compareMavenToken(left, right mavenToken) int {
if left.separator != right.separator {
if cmp := compareInt(
mavenTokenKind(left),
mavenTokenKind(right),
); cmp != 0 {
return cmp
}
}
if left.numeric && right.numeric {
return compareMavenInteger(left.value, right.value)
}
if !left.numeric && !right.numeric {
return compareMavenQualifier(left, right)
}
if left.numeric {
return 1
}
return -1
}
func mavenTokenKind(token mavenToken) int {
if !token.numeric {
return 0
}
if token.separator == '.' {
return 2
}
return 1
}
func compareMavenInteger(left, right string) int {
left = strings.TrimLeft(left, "0")
right = strings.TrimLeft(right, "0")
if left == "" {
left = "0"
}
if right == "" {
right = "0"
}
if cmp := compareInt(len(left), len(right)); cmp != 0 {
return cmp
}
return strings.Compare(left, right)
}
func compareMavenQualifier(left, right mavenToken) int {
leftValue := mavenQualifierValue(left)
rightValue := mavenQualifierValue(right)
leftRank, leftKnown := mavenQualifierRank(leftValue)
rightRank, rightKnown := mavenQualifierRank(rightValue)
if leftKnown || rightKnown {
if cmp := compareInt(leftRank, rightRank); cmp != 0 {
return cmp
}
if leftKnown && rightKnown {
return 0
}
}
return strings.Compare(leftValue, rightValue)
}
func mavenQualifierValue(token mavenToken) string {
value := normalizeMavenQualifier(token.value)
if token.followedByDigit && len(value) == 1 {
switch value {
case "a":
return "alpha"
case "b":
return "beta"
case "m":
return "milestone"
}
}
return value
}
func normalizeMavenQualifier(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func mavenQualifierRank(value string) (int, bool) {
switch value {
case "alpha":
return 0, true
case "beta":
return 1, true
case "milestone":
return 2, true
case "rc", "cr":
return 3, true
case "snapshot":
return 4, true
case "", "final", "ga", "release":
return 5, true
case "sp":
return 6, true
default:
return 7, false
}
}
package version
import (
"errors"
"strconv"
"strings"
)
// NewestMinecraft reports the highest version in the list under the Minecraft
// grammar. Entries the grammar cannot parse are skipped.
func NewestMinecraft(versions []Version) (Version, error) {
var newest Version
var parsed ResolvableVersion
for _, candidate := range versions {
if candidate == "" {
continue
}
resolved, err := ParseVersion(candidate, DialectMinecraft)
if err != nil || resolved == nil {
continue
}
if parsed == nil {
newest, parsed = candidate, resolved
continue
}
if newerMinecraft(candidate, resolved, newest, parsed) {
newest, parsed = candidate, resolved
}
}
if parsed == nil {
return "", errors.New("no Minecraft version in the candidate list")
}
return newest, nil
}
// newerMinecraft reports whether candidate is newer than current. The release
// scheme changed at year 26, and versions from different schemes do not
// compare, so a year-based version outranks every 1.x version.
func newerMinecraft(
candidate Version,
candidateParsed ResolvableVersion,
current Version,
currentParsed ResolvableVersion,
) bool {
candidateScheme := minecraftSchemeRank(candidate)
currentScheme := minecraftSchemeRank(current)
if candidateScheme != currentScheme {
return candidateScheme > currentScheme
}
comparison, comparable := Compare(currentParsed, candidateParsed)
return comparable && comparison < 0
}
// minecraftSchemeRank orders the release schemes: 1.x releases rank 0 and
// year-based releases rank 1.
func minecraftSchemeRank(v Version) int {
head, _, _ := strings.Cut(string(v), ".")
year, err := strconv.Atoi(head)
if err != nil {
return 0
}
if year >= minPost26ReleaseYear {
return 1
}
return 0
}
package version
import (
"strconv"
"strings"
)
func parseMinecraftVersion(s Version) ResolvableVersion {
if v := parseMinecraftRelease(s); v != nil {
return v
}
return parseMinecraftSnapshot(s)
}
func parseMinecraftSnapshot(s Version) ResolvableVersion {
if v := parsePre26WeekSnapshot(s); v != nil {
return v
}
return parsePost26MinecraftSnapshot(s)
}
func parsePre26WeekSnapshot(s Version) ResolvableVersion {
if len(s) < 4 {
return nil
}
index := s[len(s)-1]
v := parseSnapshotWorkCycle(s[:len(s)-1])
if v == nil {
return nil
}
if index < minSnapshotIndex || index > maxSnapshotIndex {
return nil
}
v.Index = index
if !v.Validate() {
return nil
}
return v
}
func parseSnapshotWorkCycle(s Version) *Pre26MinecraftSnapshotVersion {
tokens := strings.Split(string(s), "w")
if len(tokens) != 2 {
return nil
}
year, ok := parseUint8(tokens[0])
if !ok {
return nil
}
workCycle, ok := parseUint8(tokens[1])
if !ok {
return nil
}
return &Pre26MinecraftSnapshotVersion{
Year: year,
WorkCycle: workCycle,
Index: 0,
}
}
func parsePost26MinecraftSnapshot(s Version) ResolvableVersion {
str := string(s)
if !strings.Contains(str, "-snapshot-") {
return nil
}
tokens := strings.Split(str, "-snapshot-")
if len(tokens) != 2 {
return nil
}
core := strings.Split(tokens[0], ".")
if len(core) != 2 {
return nil
}
year, ok := parseUint8(core[0])
if !ok {
return nil
}
update, ok := parseUint8(core[1])
if !ok {
return nil
}
snapshotN, ok := parseUint8(tokens[1])
if !ok {
return nil
}
v := &Post26MinecraftSnapshotVersion{
Year: year,
Update: update,
SnapshotN: snapshotN,
}
if !v.Validate() {
return nil
}
return v
}
func parseMinecraftRelease(s Version) ResolvableVersion {
if v := parsePost26MinecraftRelease(s); v != nil {
return v
}
return parsePre26MinecraftRelease(s)
}
func parsePost26MinecraftRelease(s Version) ResolvableVersion {
str := string(s)
if str == "" || strings.Contains(str, "-snapshot-") {
return nil
}
core, suffix := splitCoreAndSuffix(str)
coreTokens := strings.Split(core, ".")
if len(coreTokens) < 2 || len(coreTokens) > 3 {
return nil
}
year, ok := parseUint8(coreTokens[0])
if !ok || year < minPost26ReleaseYear {
return nil
}
update, ok := parseUint8(coreTokens[1])
if !ok {
return nil
}
v := &MinecraftVersion{
Year: year,
Update: update,
Post26: true,
}
if len(coreTokens) == 3 {
hotfix, ok := parseUint8(coreTokens[2])
if !ok {
return nil
}
v.Hotfix = hotfix
}
if suffix != "" {
prerelease, number, ok := parseMinecraftPrereleaseSuffix(suffix)
if !ok {
return nil
}
v.Prerelease = prerelease
v.PrereleaseNumber = number
}
if !v.Validate() {
return nil
}
return v
}
func parsePre26MinecraftRelease(s Version) ResolvableVersion {
str := string(s)
if str == "" {
return nil
}
core, suffix := splitCoreAndSuffix(str)
coreTokens := strings.Split(core, ".")
if len(coreTokens) < 2 || len(coreTokens) > 3 {
return nil
}
year, ok := parseUint8(coreTokens[0])
if !ok || year >= minPost26ReleaseYear {
return nil
}
update, ok := parseUint8(coreTokens[1])
if !ok {
return nil
}
v := &MinecraftVersion{
Year: year,
Update: update,
Post26: false,
}
if len(coreTokens) == 3 {
hotfix, ok := parseUint8(coreTokens[2])
if !ok {
return nil
}
v.Hotfix = hotfix
}
if suffix != "" {
prerelease, number, ok := parseMinecraftPrereleaseSuffix(suffix)
if !ok {
return nil
}
v.Prerelease = prerelease
v.PrereleaseNumber = number
}
if !v.Validate() {
return nil
}
return v
}
func splitCoreAndSuffix(raw string) (core string, suffix string) {
core = raw
if before, after, ok := strings.Cut(raw, "-"); ok {
core = before
suffix = after
}
return core, suffix
}
func parseMinecraftPrereleaseSuffix(suffix string) (
PrereleaseType,
uint8,
bool,
) {
if after, ok := strings.CutPrefix(suffix, "pre"); ok {
number, ok := parsePrereleaseNumber(after)
if !ok {
return "", 0, false
}
return Post26Prerelease, number, true
}
if after, ok := strings.CutPrefix(suffix, "rc"); ok {
number, ok := parsePrereleaseNumber(after)
if !ok {
return "", 0, false
}
return Post26ReleaseCandidate, number, true
}
return "", 0, false
}
func parsePrereleaseNumber(s string) (uint8, bool) {
s = strings.TrimPrefix(s, "-")
if s == "" {
return 0, false
}
n, ok := parseUint8(s)
if !ok || n == 0 {
return 0, false
}
return n, true
}
func parseUint8(s string) (uint8, bool) {
if s == "" {
return 0, false
}
v, err := strconv.Atoi(s)
if err != nil || v < 0 || v > 255 {
return 0, false
}
return uint8(v), true
}
package version
import (
"fmt"
"strings"
)
// parseMinecraftConstraint accepts exact release or snapshot versions as OR
// alternatives. Minecraft metadata defines no range operators.
func parseMinecraftConstraint(raw string) (Expr, error) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "*" {
return nil, nil
}
parts := strings.Split(raw, "||")
result := make(Expr, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
return nil, fmt.Errorf("empty alternative in Minecraft range %q", raw)
}
if isWildcardExpression(part) {
return nil, nil
}
version := parseMinecraftVersion(Version(part))
if version == nil {
return nil, fmt.Errorf("invalid Minecraft version %q", part)
}
result = append(result, []SubExpr{
{Value: version, Operator: OpEq},
})
}
if len(result) == 0 {
return nil, nil
}
return result, nil
}
package version
import "strconv"
const (
maxSnapshotWeek = 54
maxSnapshotIndex = uint8('h')
minSnapshotIndex = uint8('a')
minPost26ReleaseYear = 26
)
// docs:
// https://zh.minecraft.wiki/w/%E7%89%88%E6%9C%AC%E6%A0%BC%E5%BC%8F
// https://www.minecraft.net/en-us/article/minecraft-new-version-numbering-system
type Pre26MinecraftSnapshotVersion struct {
Year uint8 // the last two digits of the year, e.g. 24 for 2024
WorkCycle uint8 // the week of the year
Index uint8 // the letter, stored as ASCII values directly
}
func (v1 *Pre26MinecraftSnapshotVersion) Compare(v2 ResolvableVersion) (
int,
bool,
) {
o, ok := v2.(*Pre26MinecraftSnapshotVersion)
if !ok || v1 == nil || o == nil {
return 0, false
}
if cmp := compareUint8(v1.Year, o.Year); cmp != 0 {
return cmp, true
}
if cmp := compareUint8(v1.WorkCycle, o.WorkCycle); cmp != 0 {
return cmp, true
}
return compareUint8(v1.Index, o.Index), true
}
func (v1 *Pre26MinecraftSnapshotVersion) Validate() bool {
if v1 == nil {
return false
}
return v1.Year != 0 &&
v1.WorkCycle > 0 && v1.WorkCycle <= maxSnapshotWeek &&
v1.Index >= minSnapshotIndex && v1.Index <= maxSnapshotIndex
}
func (v1 *Pre26MinecraftSnapshotVersion) Dialect() Dialect {
return DialectMinecraft
}
// Post26MinecraftSnapshotVersion represents snapshots after the new numbering
// scheme (for example 26.1-snapshot-1).
type Post26MinecraftSnapshotVersion struct {
Year uint8
Update uint8
SnapshotN uint8
}
func (v1 *Post26MinecraftSnapshotVersion) Compare(v2 ResolvableVersion) (
int,
bool,
) {
o, ok := v2.(*Post26MinecraftSnapshotVersion)
if !ok || v1 == nil || o == nil {
return 0, false
}
if cmp := compareUint8(v1.Year, o.Year); cmp != 0 {
return cmp, true
}
if cmp := compareUint8(v1.Update, o.Update); cmp != 0 {
return cmp, true
}
return compareUint8(v1.SnapshotN, o.SnapshotN), true
}
func (v1 *Post26MinecraftSnapshotVersion) Validate() bool {
if v1 == nil {
return false
}
return v1.Year >= minPost26ReleaseYear &&
v1.Update > 0 &&
v1.SnapshotN > 0
}
func (v1 *Post26MinecraftSnapshotVersion) Dialect() Dialect {
return DialectMinecraft
}
type MinecraftVersion struct {
Year uint8
Update uint8
Hotfix uint8
Prerelease PrereleaseType
PrereleaseNumber uint8
Post26 bool
}
type PrereleaseType string
const (
Post26Prerelease PrereleaseType = "pre"
Post26ReleaseCandidate PrereleaseType = "rc"
)
func (v *MinecraftVersion) Title() string {
if v == nil {
return ""
}
if v.Post26 {
base := strconv.Itoa(int(v.Year)) + "." + strconv.Itoa(int(v.Update))
if v.Prerelease != "" {
return base + "-" + string(v.Prerelease) + "-" + strconv.Itoa(int(v.PrereleaseNumber))
}
if v.Hotfix > 0 {
return base + "." + strconv.Itoa(int(v.Hotfix))
}
return base
}
base := strconv.Itoa(int(v.Year)) + "." + strconv.Itoa(int(v.Update))
if v.Hotfix > 0 {
base += "." + strconv.Itoa(int(v.Hotfix))
}
if v.Prerelease != "" {
return base + "-" + string(v.Prerelease) + strconv.Itoa(int(v.PrereleaseNumber))
}
return base
}
func (v1 *MinecraftVersion) Compare(v2 ResolvableVersion) (int, bool) {
o, ok := v2.(*MinecraftVersion)
if !ok || v1 == nil || o == nil {
return 0, false
}
if v1.Post26 != o.Post26 {
return 0, false
}
if cmp := compareUint8(v1.Year, o.Year); cmp != 0 {
return cmp, true
}
if cmp := compareUint8(v1.Update, o.Update); cmp != 0 {
return cmp, true
}
if cmp := compareUint8(v1.Hotfix, o.Hotfix); cmp != 0 {
return cmp, true
}
if cmp := compareUint8(
prereleaseRank(v1.Prerelease),
prereleaseRank(o.Prerelease),
); cmp != 0 {
return cmp, true
}
return compareUint8(v1.PrereleaseNumber, o.PrereleaseNumber), true
}
func (v1 *MinecraftVersion) Validate() bool {
if v1 == nil {
return false
}
if v1.Year == 0 || v1.Update == 0 {
return false
}
if v1.Post26 && v1.Year < minPost26ReleaseYear {
return false
}
switch v1.Prerelease {
case "":
return true
case Post26Prerelease, Post26ReleaseCandidate:
if v1.PrereleaseNumber == 0 {
return false
}
if v1.Post26 {
return v1.Hotfix == 0
}
return true
default:
return false
}
}
func (v1 *MinecraftVersion) Dialect() Dialect {
return DialectMinecraft
}
func prereleaseRank(pr PrereleaseType) uint8 {
switch pr {
case Post26Prerelease:
return 0
case Post26ReleaseCandidate:
return 1
default:
return 2
}
}
func compareUint8(a, b uint8) int {
if a < b {
return -1
}
if a > b {
return 1
}
return 0
}
package version
import (
"fmt"
"strings"
)
// parseOpaqueVersion accepts any non-empty identifier. Opaque versions have no
// ordering; they compare by exact string.
func parseOpaqueVersion(raw Version) ResolvableVersion {
text := string(raw)
if strings.TrimSpace(text) == "" {
return nil
}
return &StringVersion{
dialect: DialectOpaque,
original: text,
}
}
// parseOpaqueConstraint accepts an unconstrained expression or an exact
// identifier. Descriptors with an opaque version grammar define no range
// operators.
func parseOpaqueConstraint(raw string) (Expr, error) {
text := strings.TrimSpace(raw)
if text == "" || text == "*" {
return nil, nil
}
if strings.ContainsAny(text, " \t\r\n|") {
return nil, fmt.Errorf("opaque constraint %q must be an exact version", raw)
}
switch text[0] {
case '<', '>', '=', '!', '^', '~':
return nil, fmt.Errorf("opaque constraint %q must be an exact version", raw)
}
version := parseOpaqueVersion(Version(text))
if version == nil {
return nil, fmt.Errorf("invalid opaque version %q", raw)
}
return Expr{
{{Value: version, Operator: OpEq}},
}, nil
}
package version
import (
"fmt"
"strings"
)
// parseMavenRange parses Maven version ranges used by Forge and NeoForge.
// A bare version is a recommended version, not a restriction.
//
// References:
// - https://docs.minecraftforge.net/en/latest/gettingstarted/modfiles/
// - https://maven.apache.org/enforcer/enforcer-rules/versionRanges.html
func parseMavenRange(raw string) (Expr, error) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "*" || strings.EqualFold(raw, "none") {
return nil, nil
}
parts, err := splitMavenUnions(raw)
if err != nil {
return nil, err
}
result := make(Expr, 0, len(parts))
for _, part := range parts {
constraints, err := parseMavenSingleRange(strings.TrimSpace(part))
if err != nil {
return nil, err
}
if len(constraints) == 0 {
continue
}
result = append(result, constraints)
}
if len(result) == 0 {
return nil, nil
}
return result, nil
}
func splitMavenUnions(raw string) ([]string, error) {
var out []string
depth := 0
start := 0
flush := func(end int) error {
part := strings.TrimSpace(raw[start:end])
if part == "" {
return fmt.Errorf("invalid Maven range %q", raw)
}
out = append(out, part)
return nil
}
for index := range len(raw) {
switch raw[index] {
case '[', '(':
depth++
case ']', ')':
if depth == 0 {
return nil, fmt.Errorf("invalid Maven range %q", raw)
}
depth--
case ',':
if depth == 0 {
if err := flush(index); err != nil {
return nil, err
}
start = index + 1
}
}
}
if depth != 0 {
return nil, fmt.Errorf("invalid Maven range %q", raw)
}
if err := flush(len(raw)); err != nil {
return nil, err
}
return out, nil
}
func parseMavenSingleRange(raw string) ([]SubExpr, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("empty Maven range")
}
if !startsRange(raw) {
if parseMavenVersion(Version(raw)) == nil {
return nil, fmt.Errorf("invalid Maven version %q", raw)
}
return nil, nil
}
if !endsRange(raw) {
return nil, fmt.Errorf("invalid Maven range %q", raw)
}
left := raw[0]
right := raw[len(raw)-1]
if !((left == '[' || left == '(') && (right == ']' || right == ')')) {
return nil, fmt.Errorf("invalid Maven range %q", raw)
}
body := strings.TrimSpace(raw[1 : len(raw)-1])
if !strings.Contains(body, ",") {
if left == '[' && right == ']' && body != "" {
version := parseMavenVersion(Version(body))
if version == nil {
return nil, fmt.Errorf("invalid Maven version %q", body)
}
return []SubExpr{
{Value: version, Operator: OpEq},
}, nil
}
return nil, fmt.Errorf("invalid Maven range %q", raw)
}
bounds := strings.SplitN(body, ",", 2)
lowerToken := strings.TrimSpace(bounds[0])
upperToken := strings.TrimSpace(bounds[1])
var lower, upper *MavenVersion
out := make([]SubExpr, 0, 2)
if lowerToken != "" {
parsed := parseMavenVersion(Version(lowerToken))
if parsed == nil {
return nil, fmt.Errorf("invalid Maven version %q", lowerToken)
}
lower = parsed.(*MavenVersion)
operator := OpGt
if left == '[' {
operator = OpGte
}
out = append(out, SubExpr{Value: lower, Operator: operator})
}
if upperToken != "" {
parsed := parseMavenVersion(Version(upperToken))
if parsed == nil {
return nil, fmt.Errorf("invalid Maven version %q", upperToken)
}
upper = parsed.(*MavenVersion)
operator := OpLt
if right == ']' {
operator = OpLte
}
out = append(out, SubExpr{Value: upper, Operator: operator})
}
if lower != nil && upper != nil {
comparison, comparable := lower.Compare(upper)
if !comparable {
return nil, fmt.Errorf("incomparable Maven bounds in %q", raw)
}
if comparison > 0 {
return nil, fmt.Errorf("descending Maven range %q", raw)
}
if comparison == 0 && (left == '(' || right == ')') {
return nil, fmt.Errorf("empty Maven range %q", raw)
}
}
return out, nil
}
func startsRange(raw string) bool {
return strings.HasPrefix(raw, "[") || strings.HasPrefix(raw, "(")
}
func endsRange(raw string) bool {
return strings.HasSuffix(raw, "]") || strings.HasSuffix(raw, ")")
}
package version
import (
"fmt"
"strconv"
"strings"
)
// parseFabricConstraint parses fabric.mod.json dependency predicates. Each
// entry of a VersionRange array is an alternative, which ParseConstraints
// merges into OR clauses.
//
// Reference: https://wiki.fabricmc.net/documentation:fabric_mod_json_spec
func parseFabricConstraint(raw string) (Expr, error) {
raw = strings.TrimSpace(raw)
if raw == "" || isWildcardExpression(raw) {
return nil, nil
}
parts := strings.Split(raw, "||")
result := make(Expr, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
return nil, fmt.Errorf("empty alternative in Fabric range %q", raw)
}
terms, err := parseFabricClause(part)
if err != nil {
return nil, err
}
if len(terms) == 0 {
return nil, nil
}
result = append(result, terms)
}
if len(result) == 0 {
return nil, nil
}
return result, nil
}
func parseFabricClause(raw string) ([]SubExpr, error) {
if strings.Contains(raw, " - ") {
return parseFabricHyphenRange(raw)
}
tokens := strings.Fields(raw)
terms := make([]SubExpr, 0, len(tokens))
for _, token := range tokens {
parsed, err := parseFabricToken(token)
if err != nil {
return nil, err
}
terms = append(terms, parsed...)
}
return terms, nil
}
func parseFabricHyphenRange(raw string) ([]SubExpr, error) {
tokens := strings.SplitN(raw, " - ", 2)
if len(tokens) != 2 {
return nil, fmt.Errorf("invalid Fabric range %q", raw)
}
lower := parseFabricVersion(Version(strings.TrimSpace(tokens[0])))
upper := parseFabricVersion(Version(strings.TrimSpace(tokens[1])))
if lower == nil || upper == nil {
return nil, fmt.Errorf("invalid Fabric range %q", raw)
}
return []SubExpr{
{Value: lower, Operator: OpGte},
{Value: upper, Operator: OpLte},
}, nil
}
func parseFabricToken(raw string) ([]SubExpr, error) {
raw = strings.TrimSpace(raw)
if raw == "" || isWildcardExpression(raw) {
return nil, nil
}
// Comma-separated comparators are an AND group.
if strings.Contains(raw, ",") {
parts := strings.Split(raw, ",")
terms := make([]SubExpr, 0, len(parts))
for _, part := range parts {
parsed, err := parseFabricToken(part)
if err != nil {
return nil, err
}
terms = append(terms, parsed...)
}
return terms, nil
}
operator := ""
versionToken := raw
for _, candidate := range []string{
">=", "<=", "!=", "==", ">", "<", "=", "^", "~",
} {
if after, ok := strings.CutPrefix(raw, candidate); ok {
operator = candidate
versionToken = strings.TrimSpace(after)
break
}
}
if versionToken == "" {
return nil, fmt.Errorf("invalid Fabric predicate %q", raw)
}
if hasWildcardComponent(versionToken) {
switch operator {
case "", "=", "==":
return parseFabricWildcardRange(versionToken)
default:
return nil, fmt.Errorf(
"Fabric wildcard predicate %q requires equality",
raw,
)
}
}
lower := parseFabricVersion(Version(versionToken))
if lower == nil {
return nil, fmt.Errorf("invalid Fabric version %q", versionToken)
}
if _, semantic := lower.(*SemverVersion); !semantic {
switch operator {
case ">", "<", "!=":
return nil, fmt.Errorf(
"Fabric predicate %q needs a semantic version",
raw,
)
default:
// Fabric reduces inclusive predicates on plain strings to equality.
return []SubExpr{
{Value: lower, Operator: OpEq},
}, nil
}
}
switch operator {
case "", "=", "==":
return []SubExpr{{Value: lower, Operator: OpEq}}, nil
case "!=":
return []SubExpr{{Value: lower, Operator: OpNeq}}, nil
case ">":
return []SubExpr{{Value: lower, Operator: OpGt}}, nil
case ">=":
return []SubExpr{{Value: lower, Operator: OpGte}}, nil
case "<":
return []SubExpr{{Value: lower, Operator: OpLt}}, nil
case "<=":
return []SubExpr{{Value: lower, Operator: OpLte}}, nil
case "^", "~":
upper := semverUpperBound(lower.(*SemverVersion), operator)
if upper == nil {
return nil, fmt.Errorf("invalid Fabric predicate %q", raw)
}
return []SubExpr{
{Value: lower, Operator: OpGte},
{Value: upper, Operator: OpLt},
}, nil
default:
return nil, fmt.Errorf("invalid Fabric predicate %q", raw)
}
}
// parseFabricWildcardRange expands a Fabric X-range. Fabric includes
// prereleases at the lower boundary and excludes them at the next component.
func parseFabricWildcardRange(raw string) ([]SubExpr, error) {
core := raw
if index := strings.IndexByte(core, '+'); index >= 0 {
core = core[:index]
}
if strings.Contains(core, "-") {
return nil, fmt.Errorf(
"Fabric wildcard %q cannot carry a prerelease",
raw,
)
}
parts := strings.Split(core, ".")
if len(parts) < 2 {
return nil, fmt.Errorf("invalid Fabric wildcard %q", raw)
}
wildcardIndex := -1
for index, part := range parts {
if isWildcardComponent(part) {
wildcardIndex = index
break
}
}
if wildcardIndex <= 0 {
return nil, fmt.Errorf("invalid Fabric wildcard %q", raw)
}
for _, part := range parts[wildcardIndex:] {
if !isWildcardComponent(part) {
return nil, fmt.Errorf("invalid Fabric wildcard %q", raw)
}
}
parts = parts[:wildcardIndex+1]
numbers := make([]uint64, 0, wildcardIndex)
for _, part := range parts[:wildcardIndex] {
number, err := strconv.ParseUint(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid Fabric wildcard %q", raw)
}
numbers = append(numbers, number)
}
switch wildcardIndex {
case 1: // a.x behaves as ^a-
lower := newSemverBoundary(DialectFabric, numbers[0])
upper := newSemverBoundary(DialectFabric, numbers[0]+1)
return []SubExpr{
{Value: lower, Operator: OpGte},
{Value: upper, Operator: OpLt},
}, nil
case 2: // a.b.x behaves as ~a.b-
lower := newSemverBoundary(
DialectFabric,
numbers[0],
numbers[1],
)
upper := newSemverBoundary(
DialectFabric,
numbers[0],
numbers[1]+1,
)
return []SubExpr{
{Value: lower, Operator: OpGte},
{Value: upper, Operator: OpLt},
}, nil
default: // a.b.c.x increments the last numeric component.
lower := newSemverBoundary(DialectFabric, numbers...)
incremented := append([]uint64(nil), numbers...)
incremented[len(incremented)-1]++
upper := newSemverBoundary(DialectFabric, incremented...)
return []SubExpr{
{Value: lower, Operator: OpGte},
{Value: upper, Operator: OpLt},
}, nil
}
}
func semverUpperBound(
lower *SemverVersion,
operator string,
) ResolvableVersion {
switch operator {
case "^":
return newSemverBoundary(
lower.Dialect(),
lower.Major()+1,
)
case "~":
return newSemverBoundary(
lower.Dialect(),
lower.Major(),
lower.Minor()+1,
)
default:
return nil
}
}
// parseMCDRConstraint parses metadata dependency requirements. Criterions are
// space-separated and combined with AND.
//
// References:
// - https://docs.mcdreforged.com/en/latest/plugin_dev/metadata.html
// - https://github.com/MCDReforged/MCDReforged/blob/master/mcdreforged/plugin/meta/version.py
func parseMCDRConstraint(raw string) (Expr, error) {
raw = strings.TrimSpace(raw)
if raw == "" || isWildcardExpression(raw) {
return nil, nil
}
tokens := strings.Fields(raw)
terms := make([]SubExpr, 0, len(tokens))
for _, token := range tokens {
parsed, err := parseMCDRCriterion(token)
if err != nil {
return nil, err
}
terms = append(terms, parsed...)
}
if len(terms) == 0 {
return nil, nil
}
return Expr{terms}, nil
}
func parseMCDRCriterion(raw string) ([]SubExpr, error) {
raw = strings.TrimSpace(raw)
if raw == "" || isWildcardExpression(raw) {
return nil, nil
}
operator := ""
versionToken := raw
for _, candidate := range []string{
">=", "<=", "==", ">", "<", "=", "^", "~",
} {
if after, ok := strings.CutPrefix(raw, candidate); ok {
operator = candidate
versionToken = strings.TrimSpace(after)
break
}
}
if versionToken == "" {
return nil, fmt.Errorf("invalid MCDR criterion %q", raw)
}
lower := parseMCDRVersion(versionToken, true)
if lower == nil {
return nil, fmt.Errorf("invalid MCDR version %q", versionToken)
}
semantic, ok := lower.(*SemverVersion)
if !ok {
return nil, fmt.Errorf("invalid MCDR version %q", versionToken)
}
switch operator {
case "", "=", "==":
return []SubExpr{{Value: lower, Operator: OpEq}}, nil
case ">":
return []SubExpr{{Value: lower, Operator: OpGt}}, nil
case ">=":
return []SubExpr{{Value: lower, Operator: OpGte}}, nil
case "<":
return []SubExpr{{Value: lower, Operator: OpLt}}, nil
case "<=":
return []SubExpr{{Value: lower, Operator: OpLte}}, nil
case "^":
if semantic.wildcardAt(0) {
return []SubExpr{{Operator: OpNever}}, nil
}
upper := newSemverBoundary(
DialectMCDR,
semantic.Major()+1,
)
return []SubExpr{
{Value: lower, Operator: OpGte},
{Value: upper, Operator: OpLt},
}, nil
case "~":
if semantic.wildcardAt(0) || semantic.wildcardAt(1) {
return []SubExpr{{Operator: OpNever}}, nil
}
upper := newSemverBoundary(
DialectMCDR,
semantic.Major(),
semantic.Minor()+1,
)
return []SubExpr{
{Value: lower, Operator: OpGte},
{Value: upper, Operator: OpLt},
}, nil
default:
return nil, fmt.Errorf("invalid MCDR criterion %q", raw)
}
}
func hasWildcardComponent(raw string) bool {
core := raw
if index := strings.IndexByte(core, '+'); index >= 0 {
core = core[:index]
}
if index := strings.IndexByte(core, '-'); index >= 0 {
core = core[:index]
}
for _, part := range strings.Split(core, ".") {
if isWildcardComponent(part) {
return true
}
}
return false
}
func isWildcardExpression(token string) bool {
token = strings.TrimSpace(token)
if token == "" {
return true
}
return isWildcardComponent(token)
}
func isWildcardComponent(token string) bool {
return token == "*" || token == "x" || token == "X"
}
package version
import (
"encoding/json/jsontext"
"strconv"
"strings"
)
// SemverVersion implements the semantic grammar shared by Fabric Loader and
// MCDR: one or more numeric components, an optional prerelease, and build
// metadata. Build metadata never participates in comparison.
type SemverVersion struct {
dialect Dialect
numbers []uint64
wildcards []bool
prerelease string
hasPrerelease bool
original string
}
// StringVersion is a version outside the semantic grammar. Fabric Loader
// compares it as a plain string; the opaque dialect uses it for identifiers.
type StringVersion struct {
dialect Dialect
original string
}
type semverParseOptions struct {
dialect Dialect
allowWildcard bool
validateBuild bool
}
func parseSemverVersion(
raw string,
options semverParseOptions,
) *SemverVersion {
core := raw
if index := strings.IndexByte(core, '+'); index >= 0 {
build := core[index+1:]
if options.validateBuild && !validExtraIdentifiers(build, true) {
return nil
}
core = core[:index]
}
prerelease := ""
hasPrerelease := false
if index := strings.IndexByte(core, '-'); index >= 0 {
prerelease = core[index+1:]
hasPrerelease = true
core = core[:index]
if !validExtraIdentifiers(prerelease, false) {
return nil
}
}
if core == "" || strings.HasPrefix(core, ".") || strings.HasSuffix(core, ".") {
return nil
}
parts := strings.Split(core, ".")
numbers := make([]uint64, 0, len(parts))
var wildcards []bool
for _, part := range parts {
if isWildcardComponent(part) {
if !options.allowWildcard {
return nil
}
if wildcards == nil {
wildcards = make([]bool, len(parts))
}
wildcards[len(numbers)] = true
numbers = append(numbers, 0)
continue
}
number, err := strconv.ParseUint(part, 10, 64)
if err != nil {
return nil
}
numbers = append(numbers, number)
}
return &SemverVersion{
dialect: options.dialect,
numbers: numbers,
wildcards: wildcards,
prerelease: prerelease,
hasPrerelease: hasPrerelease,
original: raw,
}
}
func parseFabricVersion(raw Version) ResolvableVersion {
text := string(raw)
if text == "" {
return nil
}
if parsed := parseSemverVersion(
text,
semverParseOptions{dialect: DialectFabric},
); parsed != nil {
return parsed
}
return &StringVersion{
dialect: DialectFabric,
original: text,
}
}
func parseMCDRVersion(raw string, allowWildcard bool) ResolvableVersion {
parsed := parseSemverVersion(
raw,
semverParseOptions{
dialect: DialectMCDR,
allowWildcard: allowWildcard,
validateBuild: true,
},
)
if parsed == nil {
return nil
}
return parsed
}
// validExtraIdentifiers accepts prerelease or build identifiers allowed by the
// semantic grammars. An empty value is valid and denotes an empty prerelease.
func validExtraIdentifiers(value string, allowPlus bool) bool {
if value == "" {
return true
}
for identifier := range strings.SplitSeq(value, ".") {
if identifier == "" {
return false
}
for _, ch := range identifier {
if ch == '-' || (allowPlus && ch == '+') ||
(ch >= '0' && ch <= '9') ||
(ch >= 'A' && ch <= 'Z') ||
(ch >= 'a' && ch <= 'z') {
continue
}
return false
}
}
return true
}
// newSemverBoundary creates a version directly below every prerelease and
// above every lower core version, matching Fabric's trailing '-' predicate
// boundary.
func newSemverBoundary(
dialect Dialect,
numbers ...uint64,
) *SemverVersion {
return &SemverVersion{
dialect: dialect,
numbers: numbers,
hasPrerelease: true,
original: joinSemverNumbers(numbers) + "-",
}
}
func joinSemverNumbers(numbers []uint64) string {
parts := make([]string, len(numbers))
for index, number := range numbers {
parts[index] = strconv.FormatUint(number, 10)
}
return strings.Join(parts, ".")
}
func (s *SemverVersion) component(index int) (uint64, bool) {
if s == nil || index < 0 {
return 0, false
}
if index < len(s.numbers) {
return s.numbers[index], s.wildcards != nil && s.wildcards[index]
}
if len(s.numbers) > 0 && s.wildcards != nil && s.wildcards[len(s.numbers)-1] {
return 0, true
}
return 0, false
}
func (s *SemverVersion) Major() uint64 {
value, _ := s.component(0)
return value
}
func (s *SemverVersion) Minor() uint64 {
value, _ := s.component(1)
return value
}
func (s *SemverVersion) Patch() uint64 {
value, _ := s.component(2)
return value
}
func (s *SemverVersion) wildcardAt(index int) bool {
_, wildcard := s.component(index)
return wildcard
}
func (s *SemverVersion) hasWildcard() bool {
if s == nil {
return false
}
for _, wildcard := range s.wildcards {
if wildcard {
return true
}
}
return false
}
func (s *SemverVersion) Prerelease() string {
if s == nil {
return ""
}
return s.prerelease
}
func (s *SemverVersion) Dialect() Dialect {
if s == nil {
return DialectUnknown
}
return s.dialect
}
func (s *SemverVersion) Compare(other ResolvableVersion) (int, bool) {
if s == nil || other == nil {
return 0, false
}
switch other := other.(type) {
case *SemverVersion:
if other.dialect != s.dialect {
return 0, false
}
count := max(len(s.numbers), len(other.numbers))
for index := range count {
left, leftWildcard := s.component(index)
right, rightWildcard := other.component(index)
if leftWildcard || rightWildcard {
continue
}
if comparison := compareUint(left, right); comparison != 0 {
return comparison, true
}
}
switch s.dialect {
case DialectFabric:
return compareFabricPrerelease(s, other), true
case DialectMCDR:
return compareMCDRPrerelease(s, other), true
default:
return 0, false
}
case *StringVersion:
if other.dialect != s.dialect {
return 0, false
}
return strings.Compare(s.String(), other.String()), true
default:
return 0, false
}
}
// compareFabricPrerelease follows Fabric Loader: a release outranks its
// prereleases, numeric identifiers rank below alphanumeric ones, numeric
// identifiers compare by length, and a longer identifier list outranks a
// shorter list sharing its prefix.
func compareFabricPrerelease(left, right *SemverVersion) int {
if !left.hasPrerelease && !right.hasPrerelease {
return 0
}
if !left.hasPrerelease {
return 1
}
if !right.hasPrerelease {
return -1
}
leftParts := prereleaseIdentifiers(left.prerelease)
rightParts := prereleaseIdentifiers(right.prerelease)
for index := range min(len(leftParts), len(rightParts)) {
if comparison := comparePrereleaseIdentifier(
leftParts[index],
rightParts[index],
); comparison != 0 {
return comparison
}
}
return compareInt(len(leftParts), len(rightParts))
}
// compareMCDRPrerelease follows MCDReforged's _ExtraElement ordering. The whole
// prerelease is one element split at its final dot into a body and an optional
// numeric suffix.
func compareMCDRPrerelease(left, right *SemverVersion) int {
switch {
case left.hasPrerelease && right.hasPrerelease:
return compareMCDRExtra(left.prerelease, right.prerelease)
case left.hasPrerelease:
if right.hasWildcard() {
return 0
}
return -1
case right.hasPrerelease:
if left.hasWildcard() {
return 0
}
return 1
default:
return 0
}
}
type mcdrExtra struct {
text string
body string
number int64
hasNum bool
}
func parseMCDRExtra(value string) mcdrExtra {
extra := mcdrExtra{text: value, body: value}
if index := strings.LastIndexByte(value, '.'); index >= 0 {
if number, err := strconv.ParseInt(value[index+1:], 10, 64); err == nil {
extra.body = value[:index]
extra.number = number
extra.hasNum = true
}
}
return extra
}
func (e mcdrExtra) String() string {
if e.hasNum {
return e.body + "." + strconv.FormatInt(e.number, 10)
}
return e.text
}
func (e mcdrExtra) less(other mcdrExtra) bool {
if !e.hasNum || !other.hasNum {
return e.String() < other.String()
}
if e.body != other.body {
return e.body < other.body
}
return e.number < other.number
}
func compareMCDRExtra(left, right string) int {
leftExtra := parseMCDRExtra(left)
rightExtra := parseMCDRExtra(right)
if leftExtra.less(rightExtra) {
return -1
}
if rightExtra.less(leftExtra) {
return 1
}
return 0
}
func prereleaseIdentifiers(prerelease string) []string {
if prerelease == "" {
return nil
}
return strings.Split(prerelease, ".")
}
func comparePrereleaseIdentifier(left, right string) int {
leftNumeric := isUnsignedInteger(left)
rightNumeric := isUnsignedInteger(right)
switch {
case leftNumeric && rightNumeric:
if comparison := compareInt(len(left), len(right)); comparison != 0 {
return comparison
}
return strings.Compare(left, right)
case leftNumeric:
return -1
case rightNumeric:
return 1
default:
return strings.Compare(left, right)
}
}
func isUnsignedInteger(value string) bool {
if value == "0" {
return true
}
if value == "" || value[0] == '0' {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func compareUint(left, right uint64) int {
switch {
case left < right:
return -1
case left > right:
return 1
default:
return 0
}
}
func compareInt(left, right int) int {
if left < right {
return -1
}
if left > right {
return 1
}
return 0
}
func (s *SemverVersion) Validate() bool {
return s != nil && len(s.numbers) > 0
}
func (s *SemverVersion) String() string {
if s == nil {
return ""
}
return s.original
}
// MarshalJSONTo renders the version in its original string form. The parsed
// components are unexported, which encoding/json/v2 refuses to marshal.
func (s *SemverVersion) MarshalJSONTo(enc *jsontext.Encoder) error {
return enc.WriteToken(jsontext.String(s.String()))
}
func (v *StringVersion) Dialect() Dialect {
if v == nil {
return DialectUnknown
}
return v.dialect
}
func (v *StringVersion) Compare(other ResolvableVersion) (int, bool) {
if v == nil || other == nil || other.Dialect() != v.dialect {
return 0, false
}
switch other := other.(type) {
case *StringVersion:
if v.dialect == DialectOpaque {
if v.original != other.original {
return 0, false
}
return 0, true
}
return strings.Compare(v.original, other.original), true
case *SemverVersion:
return strings.Compare(v.original, other.String()), true
default:
return 0, false
}
}
func (v *StringVersion) Validate() bool {
return v != nil && v.original != ""
}
func (v *StringVersion) String() string {
if v == nil {
return ""
}
return v.original
}
// MarshalJSONTo renders the version verbatim for the same reason as
// SemverVersion.
func (v *StringVersion) MarshalJSONTo(enc *jsontext.Encoder) error {
return enc.WriteToken(jsontext.String(v.String()))
}
// Package version owns version vocabulary, parsing, ordering, and constraint
// matching for every package dialect that lucy resolves.
package version
// Version is a package version. It may hold a selector during resolution or
// an exact version after resolution.
type Version string
func (v Version) String() string {
switch v {
case Any, "":
return "any"
case None:
return "none"
case Unknown:
return "unknown"
case Beta:
return "beta"
case Stable:
return "stable"
}
return string(v)
}
func (v Version) CanInfer() bool {
switch v {
case Any, Beta, Stable:
return true
}
return false
}
func (v Version) IsInvalid() bool {
switch v {
case None, Unknown:
return true
}
return false
}
var (
Any Version = "any"
Beta Version = "beta"
Stable Version = "stable"
None Version = "none"
Unknown Version = "unknown"
)
// Dialect identifies a version grammar together with the ordering and
// constraint semantics that grammar defines.
type Dialect string
const (
DialectUnknown Dialect = ""
// DialectFabric is the version grammar of fabric.mod.json metadata.
// Reference: https://wiki.fabricmc.net/documentation:fabric_mod_json_spec
DialectFabric Dialect = "fabric"
// DialectMCDR is the version grammar of mcdreforged.plugin.json metadata.
// Reference: https://docs.mcdreforged.com/en/latest/plugin_dev/metadata.html
DialectMCDR Dialect = "mcdr"
// DialectMaven is Maven version ordering, used by Forge and NeoForge
// mods.toml metadata. Reference:
// https://maven.apache.org/enforcer/enforcer-rules/versionRanges.html
DialectMaven Dialect = "maven"
// DialectMinecraft is the Minecraft release and snapshot grammar.
// Reference: https://zh.minecraft.wiki/w/%E7%89%88%E6%9C%AC%E6%A0%BC%E5%BC%8F
DialectMinecraft Dialect = "minecraft"
// DialectOpaque treats versions as unordered identifiers. It serves
// descriptors whose grammar defines no version ranges, such as plugin.yml.
DialectOpaque Dialect = "opaque"
)
// Valid reports whether d is a dialect defined by this package.
func (d Dialect) Valid() bool {
switch d {
case DialectFabric,
DialectMCDR,
DialectMaven,
DialectMinecraft,
DialectOpaque:
return true
default:
return false
}
}
// ResolvableVersion is a parsed version that belongs to exactly one dialect.
//
// A nil ResolvableVersion represents an invalid or unparseable version.
// Implementations reject comparisons against a different dialect.
type ResolvableVersion interface {
Compare(v2 ResolvableVersion) (int, bool)
Validate() bool
Dialect() Dialect
}
package discovery
import (
"bufio"
"encoding/json/v2"
"path/filepath"
"regexp"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
versionpkg "github.com/mclucy/lucy/version"
)
var arclightJarNamePattern = regexp.MustCompile(
`^arclight-(?:forge|neoforge|fabric)-(\d+\.\d+(?:\.\d+)?)(?:[-.].*)?\.jar$`,
)
// discoverArclight recognizes Arclight hybrid server jars. Loader evidence is
// kept as a concrete runtime component; the jar itself is the sole entry
// point of the installation.
func discoverArclight(s *scan) ([]Installation, []Finding) {
var installations []Installation
var findings []Finding
for _, primary := range s.RootFiles() {
if !productCandidate(s, primary) {
continue
}
installation, err := inspectArclightInstallation(s, primary)
if err != nil {
findings = append(findings, Finding{
Kind: FindingUnreadableFile,
Path: primary.Path(),
Cause: err,
})
continue
}
if installation != nil {
installations = append(installations, *installation)
}
}
return installations, findings
}
// Sources:
// - https://arclight.izzel.io/
// - https://deepwiki.com/IzzelAliz/Arclight/1-overview
func inspectArclightInstallation(
s *scan,
primary *artifact,
) (*Installation, error) {
manifest, ok, err := readProductEntry(s, primary, bukkitManifestPath)
if err != nil {
return nil, err
}
if !ok {
return nil, nil
}
manifestSignals := parseArclightManifest(manifest)
if !manifestSignals.valid() {
return nil, nil
}
launchProps, hasLaunchProps, err := readProductEntry(
s,
primary,
"arclight-server-launch.properties",
)
if err != nil {
return nil, err
}
hasCommonJar, err := productEntryExists(s, primary, "common.jar")
if err != nil {
return nil, err
}
if !hasLaunchProps && !hasCommonJar {
return nil, nil
}
gameVersion := manifestSignals.gameVersion
if !hasConcreteVersion(gameVersion) {
gameVersion = parseArclightGameVersionFromPath(primary.Path())
}
components := []catalog.VersionedRef{
{
Ecosystem: catalog.EcosystemMinecraft,
Name: input.ToProjectName("minecraft"),
Version: gameVersion,
},
}
if loader, ok := arclightLoaderEcosystem(launchProps); ok {
component := arclightLoaderComponent(loader)
installerJSON, found, err := readProductEntry(
s,
primary,
"META-INF/installer.json",
)
if err != nil {
return nil, err
}
if found {
if loaderVersion, ok := parseArclightLoaderVersion(
installerJSON,
loader,
); ok {
component.Version = loaderVersion
}
}
components = append(components, component)
}
identity := Identity{
Primary: catalog.VersionedRef{
Ecosystem: catalog.EcosystemUnspecified,
Name: input.ToProjectName("arclight"),
Version: observedVersion(manifestSignals.implementationVersion),
},
Components: components,
}
installation := NewInstallation(identity, primary.Path())
return &installation, nil
}
type arclightManifestSignals struct {
mainClass string
implementation string
implementationVersion versionpkg.Version
gameVersion versionpkg.Version
}
func (s arclightManifestSignals) valid() bool {
return s.mainClass == "io.izzel.arclight.server.Launcher" &&
s.implementation == "Arclight"
}
func parseArclightManifest(data []byte) arclightManifestSignals {
var signals arclightManifestSignals
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "Main-Class: "):
signals.mainClass = strings.TrimSpace(
strings.TrimPrefix(
line,
"Main-Class: ",
),
)
case strings.HasPrefix(line, "Implementation-Title: "):
signals.implementation = strings.TrimSpace(
strings.TrimPrefix(
line,
"Implementation-Title: ",
),
)
case strings.HasPrefix(line, "Implementation-Version: "):
version := strings.TrimSpace(
strings.TrimPrefix(
line,
"Implementation-Version: ",
),
)
signals.implementationVersion = versionpkg.Version(version)
if parsedGameVersion := parseArclightGameVersionFromImplementation(version); hasConcreteVersion(parsedGameVersion) {
signals.gameVersion = parsedGameVersion
}
}
}
return signals
}
func parseArclightGameVersionFromImplementation(version string) versionpkg.Version {
if !strings.HasPrefix(version, "arclight-") {
return versionpkg.Unknown
}
trimmed := strings.TrimPrefix(version, "arclight-")
parts := strings.Split(trimmed, "-")
if len(parts) == 0 || !isMinecraftReleaseVersion(parts[0]) {
return versionpkg.Unknown
}
return versionpkg.Version(parts[0])
}
func parseArclightGameVersionFromPath(filePath string) versionpkg.Version {
match := arclightJarNamePattern.FindStringSubmatch(filepath.Base(filePath))
if match == nil {
return versionpkg.Unknown
}
return versionpkg.Version(match[1])
}
func arclightLoaderEcosystem(launchProps []byte) (catalog.Ecosystem, bool) {
scanner := bufio.NewScanner(strings.NewReader(string(launchProps)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok || strings.TrimSpace(key) != "launch.mainClass" {
continue
}
switch strings.TrimSpace(value) {
case "io.izzel.arclight.boot.fabric.application.Main_Fabric":
return catalog.EcosystemFabric, true
case "io.izzel.arclight.boot.neoforge.application.Main_Neoforge":
return catalog.EcosystemNeoForge, true
case "io.izzel.arclight.boot.forge.application.Main_Forge":
return catalog.EcosystemForge, true
default:
return catalog.EcosystemUnspecified, false
}
}
return catalog.EcosystemUnspecified, false
}
type arclightInstallerMetadata struct {
Installer struct {
Forge string `json:"forge"`
NeoForge string `json:"neoforge"`
FabricLoader string `json:"fabricLoader"`
} `json:"installer"`
}
func arclightLoaderComponent(
loader catalog.Ecosystem,
) catalog.VersionedRef {
var name catalog.Name
switch loader {
case catalog.EcosystemFabric:
name = "fabric-loader"
case catalog.EcosystemNeoForge:
name = "neoforge"
case catalog.EcosystemForge:
name = "forge"
}
return catalog.VersionedRef{
Ecosystem: loader,
Name: name,
Version: versionpkg.Unknown,
}
}
func parseArclightLoaderVersion(
data []byte,
loader catalog.Ecosystem,
) (versionpkg.Version, bool) {
var metadata arclightInstallerMetadata
if err := json.Unmarshal(data, &metadata); err != nil {
return versionpkg.Unknown, false
}
var version string
switch loader {
case catalog.EcosystemFabric:
version = metadata.Installer.FabricLoader
case catalog.EcosystemNeoForge:
version = metadata.Installer.NeoForge
case catalog.EcosystemForge:
version = metadata.Installer.Forge
}
loaderVersion := versionpkg.Version(strings.TrimSpace(version))
if !hasConcreteVersion(loaderVersion) {
return versionpkg.Unknown, false
}
return loaderVersion, true
}
package discovery
import (
"bufio"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
"github.com/mclucy/lucy/version"
)
const (
bukkitManifestPath = "META-INF/MANIFEST.MF"
bukkitManifestMainClass = "org.bukkit.craftbukkit.Main"
bukkitImplementationCraftBukkit = "CraftBukkit"
bukkitPaperClassPrefix = "io/papermc/paper/"
bukkitLegacyPaperClassPrefix = "com/destroystokyo/paper/"
)
var bukkitVersionPrefixPattern = regexp.MustCompile(`^(\d+\.\d+(?:\.\d+)?)`)
// paperLineages must cover every lineage inferPaperObservationLineages can
// attribute; the fork-lineage fixture test iterates this map.
var paperLineages = map[string]bool{
"paper": true,
"folia": true,
"divine": true,
"purpur": true,
"leaf": true,
"leaves": true,
"reaper": true,
"youer": true,
}
// IsPaperLineage reports whether name is a paper-lineage runtime identity
// this package can emit. Youer is included even though it is a NeoForge
// hybrid; treat it separately before calling.
func IsPaperLineage(name string) bool {
return paperLineages[name]
}
type bukkitManifestSignals struct {
mainClass string
specificationTitle string
specificationVendor string
implementationTitle string
implementationVendor string
implementationVer string
}
// discoverBukkit recognizes CraftBukkit-derived server layouts. A single
// confirmed artifact is one installation; contradictory paper-lineage claims
// are returned as separate installations so the engine can report typed
// ambiguity instead of inventing a runtime.
func discoverBukkit(s *scan) ([]Installation, []Finding) {
var installations []Installation
var findings []Finding
for _, primary := range s.RootFiles() {
if !productCandidate(s, primary) {
continue
}
detected, err := inspectBukkitInstallation(s, primary)
if err != nil {
findings = append(findings, Finding{
Kind: FindingUnreadableFile,
Path: primary.Path(),
Cause: err,
})
continue
}
installations = append(installations, detected...)
}
return installations, findings
}
func inspectBukkitInstallation(
s *scan,
primary *artifact,
) ([]Installation, error) {
var judgment paperJudgment
manifest, ok, err := readBukkitManifest(s, primary)
if err != nil {
return nil, err
}
if !ok {
return nil, nil
}
signals := parseBukkitManifest(manifest)
metaMainClass, err := readBukkitSidecarString(
s,
primary,
paperMetaMainClassPath,
)
if err != nil {
return nil, err
}
reaperPatchProperties, err := readBukkitPatchProperties(s, primary)
if err != nil {
return nil, err
}
// Stage 1: Bukkit Confirmation
// CraftBukkit-derived servers consistently launch through
// org.bukkit.craftbukkit.Main, while Implementation-Title: CraftBukkit is the
// fallback family marker seen in repackaged jars that keep the canonical
// implementation title. Extracted modern Paper fixtures keep the decisive
// Bukkit entrypoint in META-INF/main-class, while strict launcher-heavy Reaper
// and Youer fixtures only expose definitive Paper-fork proof via patch or
// manifest identity. Without one of these strict signals, we should not claim
// a Bukkit-lineage server executable.
judgment.bukkitConfirmed = signals.mainClass == bukkitManifestMainClass ||
strings.EqualFold(
signals.implementationTitle,
bukkitImplementationCraftBukkit,
) ||
metaMainClass == bukkitManifestMainClass ||
hasStrictReaperBukkitConfirmation(reaperPatchProperties) ||
hasStrictYouerBukkitConfirmation(signals)
if !judgment.bukkitConfirmed {
return nil, nil
}
// Stage 2: Observation Extraction
judgment.observations, err = extractPaperObservations(s, primary)
if err != nil {
return nil, err
}
// Stage 3: Family Reasoning
reasonPaperFamily(&judgment)
// Stage 4: Lineage Attribution
attributePaperLineage(&judgment)
// Stage 5: Contradiction Resolution
resolvePaperContradictions(&judgment)
// Stage 6: Runtime Projection
gameVersion := judgment.observations.gameVersion
if !hasConcreteVersion(gameVersion) {
gameVersion = version.Unknown
}
return projectPaperInstallations(primary.Path(), gameVersion, judgment), nil
}
func reasonPaperFamily(judgment *paperJudgment) {
if judgment == nil {
return
}
switch {
case judgment.observations.hasPaperClasses:
judgment.familyResult = familyStrong
case hasModernPaperclipMetadataCluster(judgment.observations):
judgment.familyResult = familyStrong
case hasConcreteVersion(judgment.observations.versionJSONID):
judgment.familyResult = familyWeak
case len(judgment.observations.patchProperties) > 0 || judgment.observations.hasPaperMCPatch:
judgment.familyResult = familyWeak
case judgment.observations.hasPaperclipNamespace || judgment.observations.hasLegacyPaperclipNamespace:
judgment.familyResult = familyWeak
default:
judgment.familyResult = familyMiss
}
}
func hasModernPaperclipMetadataCluster(obs paperObservations) bool {
return strings.TrimSpace(obs.downloadContext) != "" &&
len(obs.librariesListEntries) > 0 &&
strings.TrimSpace(obs.metaMainClass) != ""
}
func attributePaperLineage(judgment *paperJudgment) {
if judgment == nil {
return
}
lineages := inferPaperObservationLineages(judgment.observations)
judgment.lineages = lineages
switch {
case len(lineages) > 1:
judgment.lineageResult = lineageContradiction
judgment.lineageName = strings.Join(lineages, ",")
case len(lineages) == 1 && lineages[0] == "paper":
judgment.lineageResult = lineagePaper
judgment.lineageName = "paper"
case len(lineages) == 1:
judgment.lineageResult = lineageFork
judgment.lineageName = lineages[0]
default:
judgment.lineageResult = lineageUnknown
}
}
func inferPaperObservationLineages(obs paperObservations) []string {
lineages := make([]string, 0, 8)
seen := make(map[string]struct{}, 8)
add := func(name string) {
normalized := normalizePaperLineageName(name)
if normalized == "" {
return
}
if _, ok := seen[normalized]; ok {
return
}
seen[normalized] = struct{}{}
lineages = append(lineages, normalized)
}
// Fixture citation: workspace/discovery/testdata/paper_family/test_paper/paper/META-INF/libraries.list
if observationLinesContain(
obs.librariesListEntries,
paperLibraryPaperToken,
) {
add("paper")
}
// Fixture citation: workspace/discovery/testdata/paper_family/test_folia/folia/META-INF/libraries.list
if observationLinesContain(
obs.librariesListEntries,
paperLibraryFoliaToken,
) {
add("folia")
}
// Fixture citation: workspace/discovery/testdata/paper_family/test_divine/divine/META-INF/libraries.list
if observationLinesContain(
obs.librariesListEntries,
paperLibraryDivineToken,
) {
add("divine")
}
// Fixture citation: workspace/discovery/testdata/paper_family/test_purpur/purpur/META-INF/libraries.list
if observationLinesContain(
obs.librariesListEntries,
paperLibraryPurpurToken,
) {
add("purpur")
}
// Fixture citation: workspace/discovery/testdata/paper_family/test_leaf/leaf/META-INF/libraries.list and cn/dreeam/leaper/*
if observationLinesContain(
obs.librariesListEntries,
paperLibraryLeafToken,
) || obs.hasLeaperNamespace {
add("leaf")
}
// Fixture citation: workspace/discovery/testdata/paper_family/test_leaves/leaves/META-INF/libraries.list, META-INF/build-info, META-INF/leavesclip-version
if observationLinesContain(
obs.librariesListEntries,
paperLibraryLeavesToken,
) || obs.hasLeavesclipNamespace || obs.leavesclipVersion != "" || strings.HasPrefix(
obs.buildInfo,
"Leaves\t",
) {
add("leaves")
}
// Fixture citation: workspace/discovery/testdata/paper_family/test_reaper/reaper/patch.properties
if hasStrictReaperObservationLineage(obs) {
add("reaper")
}
// Fixture citation: workspace/discovery/testdata/paper_family/test_youer/youer/META-INF/MANIFEST.MF
if obs.hasYouerNamespace ||
strings.EqualFold(
obs.manifestSpecificationTitle,
paperManifestYouerToken,
) ||
strings.EqualFold(
obs.manifestImplementationTitle,
paperManifestYouerToken,
) ||
strings.Contains(
strings.ToLower(obs.manifestMainClass),
paperMainClassYouerToken,
) {
add("youer")
}
slices.Sort(lineages)
return lineages
}
func readBukkitManifest(s *scan, primary *artifact) ([]byte, bool, error) {
return readProductEntry(s, primary, bukkitManifestPath)
}
func readBukkitSidecarString(
s *scan,
primary *artifact,
entryPath string,
) (string, error) {
data, ok, err := readProductEntry(s, primary, entryPath)
if err != nil || !ok {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
func readBukkitPatchProperties(
s *scan,
primary *artifact,
) (map[string]string, error) {
data, ok, err := readProductEntry(s, primary, paperPatchPropertiesPath)
if err != nil || !ok {
return nil, err
}
return parsePaperPatchProperties(data), nil
}
func hasStrictReaperBukkitConfirmation(properties map[string]string) bool {
return strings.Contains(properties["patch"], paperPatchReaperToken)
}
func hasStrictYouerBukkitConfirmation(signals bukkitManifestSignals) bool {
return strings.EqualFold(
signals.specificationTitle,
paperManifestYouerToken,
) ||
strings.EqualFold(signals.implementationTitle, paperManifestYouerToken) ||
strings.Contains(
strings.ToLower(signals.mainClass),
paperMainClassYouerToken,
)
}
func hasStrictReaperObservationLineage(obs paperObservations) bool {
return strings.Contains(obs.patchProperties["patch"], paperPatchReaperToken)
}
func observationLinesContain(lines []string, want string) bool {
for _, line := range lines {
if strings.Contains(line, want) {
return true
}
}
return false
}
func resolvePaperContradictions(judgment *paperJudgment) {
if judgment == nil {
return
}
if judgment.lineageResult == lineageContradiction {
judgment.familyResult = familyContradiction
}
judgment.contradicted = judgment.familyResult == familyContradiction
}
// projectPaperInstallations emits one installation per attributable lineage.
// Contradictory claims are never merged into one fabricated identity; the
// engine reports the ambiguity and strict selection refuses it.
func projectPaperInstallations(
filePath string,
gameVersion version.Version,
judgment paperJudgment,
) []Installation {
if !judgment.bukkitConfirmed {
return nil
}
components := []catalog.VersionedRef{
{
Ecosystem: catalog.EcosystemMinecraft,
Name: input.ToProjectName("minecraft"),
Version: gameVersion,
},
}
if judgment.contradicted {
installations := make([]Installation, 0, len(judgment.lineages))
for _, lineage := range judgment.lineages {
name := normalizePaperLineageName(lineage)
if name == "" {
continue
}
installations = append(
installations,
newPaperInstallation(name, components, filePath),
)
}
if len(installations) > 0 {
return installations
}
}
lineage := "bukkit"
switch judgment.lineageResult {
case lineagePaper:
lineage = "paper"
case lineageFork:
if detected := normalizePaperLineageName(judgment.lineageName); detected != "" {
lineage = detected
}
case lineageUnknown, lineageContradiction:
// No attributable lineage retains the confirmed bukkit baseline.
}
return []Installation{newPaperInstallation(lineage, components, filePath)}
}
func newPaperInstallation(
lineage string,
components []catalog.VersionedRef,
filePath string,
) Installation {
return NewInstallation(
Identity{
Primary: catalog.VersionedRef{
Ecosystem: paperLineageEcosystem(lineage),
Name: input.ToProjectName(lineage),
Version: paperBuildFromFilename(filePath, lineage, components),
},
Components: cloneComponents(components),
},
filePath,
)
}
// paperBuildFromFilename recovers the build number that the paperclip jar
// itself does not carry. The convention is <lineage>-<minecraft>-<build>.jar;
// a renamed jar falls back to the published-build digest in init.
func paperBuildFromFilename(
filePath string,
lineage string,
components []catalog.VersionedRef,
) version.Version {
gameVersion := version.Unknown
for _, component := range components {
if component.Ecosystem == catalog.EcosystemMinecraft {
gameVersion = component.Version
break
}
}
if gameVersion == version.Unknown {
return version.Unknown
}
pattern := regexp.MustCompile(
`^` + regexp.QuoteMeta(lineage) +
`-(\d+(?:\.\d+)*)-(\d+)(?:[-+].*)?\.jar$`,
)
match := pattern.FindStringSubmatch(filepath.Base(filePath))
if len(match) != 3 || match[1] != gameVersion.String() {
return version.Unknown
}
return version.Version(match[2])
}
func paperLineageEcosystem(lineage string) catalog.Ecosystem {
switch lineage {
case "bukkit":
return catalog.EcosystemBukkit
case "youer":
// Youer is a NeoForge hybrid; its primary product is not a Paper
// ecosystem, and served-package policy handles it by name.
return catalog.EcosystemUnspecified
default:
return catalog.EcosystemPaper
}
}
func cloneComponents(
components []catalog.VersionedRef,
) []catalog.VersionedRef {
if len(components) == 0 {
return nil
}
return append([]catalog.VersionedRef(nil), components...)
}
// observedVersion keeps an unparseable or absent artifact version observable
// as version.Unknown instead of producing an invalid identity.
func observedVersion(value version.Version) version.Version {
if !hasConcreteVersion(value) {
return version.Unknown
}
return value
}
func normalizePaperLineageName(name string) string {
normalized := strings.ToLower(strings.TrimSpace(name))
if normalized == "" {
return ""
}
switch normalized {
case "craftbukkit", "bukkit":
return ""
default:
return normalized
}
}
func parseBukkitManifest(data []byte) bukkitManifestSignals {
var signals bukkitManifestSignals
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "Main-Class: "):
signals.mainClass = strings.TrimSpace(
strings.TrimPrefix(
line,
"Main-Class: ",
),
)
case strings.HasPrefix(line, "Implementation-Title: "):
signals.implementationTitle = strings.TrimSpace(
strings.TrimPrefix(
line,
"Implementation-Title: ",
),
)
case strings.HasPrefix(line, "Specification-Title: "):
signals.specificationTitle = strings.TrimSpace(
strings.TrimPrefix(
line,
"Specification-Title: ",
),
)
case strings.HasPrefix(line, "Specification-Vendor: "):
signals.specificationVendor = strings.TrimSpace(
strings.TrimPrefix(
line,
"Specification-Vendor: ",
),
)
case strings.HasPrefix(line, "Implementation-Version: "):
signals.implementationVer = strings.TrimSpace(
strings.TrimPrefix(
line,
"Implementation-Version: ",
),
)
case strings.HasPrefix(line, "Implementation-Vendor: "):
signals.implementationVendor = strings.TrimSpace(
strings.TrimPrefix(
line,
"Implementation-Vendor: ",
),
)
}
}
return signals
}
func parseBukkitGameVersion(implementationVersion string) version.Version {
match := bukkitVersionPrefixPattern.FindStringSubmatch(strings.TrimSpace(implementationVersion))
if len(match) < 2 || !isMinecraftReleaseVersion(match[1]) {
return version.Unknown
}
return version.Version(match[1])
}
// productCandidate reports whether a root artifact can carry an executable
// server layout. Directory layouts are only considered when they expose the
// manifest every recognized server product ships.
func productCandidate(s *scan, primary *artifact) bool {
if primary == nil {
return false
}
if !primary.IsDirectory() {
return strings.HasSuffix(
strings.ToLower(primary.Path()),
".jar",
)
}
manifest, ok := s.File(
filepath.Join(
primary.Path(),
filepath.FromSlash(bukkitManifestPath),
),
)
return ok && !manifest.IsDirectory()
}
// readProductEntry reads one entry from a jar artifact or from an unpacked
// directory layout artifact. Directory reads stay root-confined through the
// scan; archives use the scan-owned ReaderAt handle.
func readProductEntry(
s *scan,
primary *artifact,
entryPath string,
) ([]byte, bool, error) {
if primary == nil {
return nil, false, nil
}
if primary.IsDirectory() {
nested, ok := s.File(
filepath.Join(
primary.Path(),
filepath.FromSlash(entryPath),
),
)
if !ok || nested.IsDirectory() {
return nil, false, nil
}
data, err := nested.Read()
if err != nil {
return nil, false, err
}
return data, true, nil
}
archive, err := primary.Archive()
if err != nil {
return nil, false, err
}
if archive == nil {
return nil, false, nil
}
return readArchiveEntry(archive, entryPath)
}
// productEntryExists reports whether one entry is present without loading its
// bytes. Large payload jars are never read just to test for a marker.
func productEntryExists(
s *scan,
primary *artifact,
entryPath string,
) (bool, error) {
if primary == nil {
return false, nil
}
if primary.IsDirectory() {
nested, ok := s.File(
filepath.Join(
primary.Path(),
filepath.FromSlash(entryPath),
),
)
return ok && !nested.IsDirectory(), nil
}
archive, err := primary.Archive()
if err != nil {
return false, err
}
if archive == nil {
return false, nil
}
for _, file := range archive.File {
if file.Name == entryPath {
return true, nil
}
}
return false, nil
}
package discovery
import (
"bufio"
"regexp"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
versionpkg "github.com/mclucy/lucy/version"
)
var catServerVersionPattern = regexp.MustCompile(
`^(?:git-CatServer-)?(\d+\.\d+(?:\.\d+)?)-`,
)
// discoverCatServer recognizes CatServer hybrid jars and keeps the Minecraft
// version the manifest declares.
func discoverCatServer(s *scan) ([]Installation, []Finding) {
var installations []Installation
var findings []Finding
for _, primary := range s.RootFiles() {
if !productCandidate(s, primary) {
continue
}
installation, err := inspectCatServerInstallation(s, primary)
if err != nil {
findings = append(findings, Finding{
Kind: FindingUnreadableFile,
Path: primary.Path(),
Cause: err,
})
continue
}
if installation != nil {
installations = append(installations, *installation)
}
}
return installations, findings
}
func inspectCatServerInstallation(
s *scan,
primary *artifact,
) (*Installation, error) {
manifest, ok, err := readProductEntry(s, primary, bukkitManifestPath)
if err != nil {
return nil, err
}
if !ok {
return nil, nil
}
signals := parseCatServerManifest(manifest)
if !signals.valid() {
return nil, nil
}
identity := Identity{
Primary: catalog.VersionedRef{
Ecosystem: catalog.EcosystemUnspecified,
Name: input.ToProjectName("catserver"),
Version: signals.version,
},
Components: []catalog.VersionedRef{
{
Ecosystem: catalog.EcosystemMinecraft,
Name: input.ToProjectName("minecraft"),
Version: signals.gameVersion,
},
},
}
installation := NewInstallation(identity, primary.Path())
return &installation, nil
}
type catServerManifestSignals struct {
mainClass string
title string
version versionpkg.Version
gameVersion versionpkg.Version
}
func (s catServerManifestSignals) valid() bool {
return s.title == "CatServer" &&
s.mainClass == "catserver.server.CatServerLaunch" &&
hasConcreteVersion(s.gameVersion)
}
func parseCatServerManifest(data []byte) catServerManifestSignals {
var signals catServerManifestSignals
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "Main-Class: "):
signals.mainClass = strings.TrimSpace(
strings.TrimPrefix(
line,
"Main-Class: ",
),
)
case strings.HasPrefix(line, "Implementation-Title: "):
signals.title = strings.TrimSpace(
strings.TrimPrefix(
line,
"Implementation-Title: ",
),
)
case strings.HasPrefix(line, "Implementation-Version: "):
version := strings.TrimSpace(
strings.TrimPrefix(
line,
"Implementation-Version: ",
),
)
signals.version = versionpkg.Version(version)
signals.gameVersion = parseCatServerGameVersion(version)
}
}
return signals
}
func parseCatServerGameVersion(version string) versionpkg.Version {
match := catServerVersionPattern.FindStringSubmatch(version)
if match == nil {
return versionpkg.Unknown
}
return versionpkg.Version(match[1])
}
package discovery
import (
"path/filepath"
"slices"
"sort"
"strings"
"github.com/mclucy/lucy/server"
)
// Discover scans one server directory through the static directory recognizer
// registry and reconciles every recognizer output into a single observation.
// It does not read workspace declarations and does not choose a runtime; call
// Select afterwards.
func Discover(serverDir string) (result Result) {
result.Selection = Selection{State: SelectionNone}
scan, err := newScan(serverDir)
if err != nil {
result.Diagnostics = append(result.Diagnostics, Finding{
Kind: FindingUnreadableFile,
Path: serverDir,
Cause: err,
})
return result
}
defer func() {
if closeErr := scan.Close(); closeErr != nil {
result.Diagnostics = append(result.Diagnostics, Finding{
Kind: FindingUnreadableFile,
Path: scan.root,
Cause: closeErr,
})
}
}()
raw := make([]Installation, 0, len(recognizerRegistry))
for _, entry := range recognizerRegistry {
installations, findings := entry.discover(scan)
raw = append(raw, installations...)
result.Diagnostics = append(result.Diagnostics, findings...)
}
installations, findings := reconcileInstallations(raw, scan)
result.Installations = installations
result.Diagnostics = append(result.Diagnostics, findings...)
return result
}
func reconcileInstallations(
raw []Installation,
scan *scan,
) ([]Installation, []Finding) {
var findings []Finding
installations := make([]Installation, 0, len(raw))
for index := range raw {
installation := raw[index]
if len(installation.Files) == 0 {
continue
}
if err := installation.Identity.Normalize(); err != nil {
path := firstEntryPointPath(&installation)
if path == "" {
path = firstFilePath(installation)
}
findings = append(findings, Finding{
Kind: FindingInvalidIdentity,
Path: path,
Identity: installation.Identity,
Cause: err,
})
}
installations = append(installations, installation)
}
installations = shadowVanillaClaims(installations)
installations = mergeIdenticalInstallations(installations)
installations = absorbVanillaPayloads(installations)
installations = dropEmptyInstallations(installations)
findings = append(findings, conflictingClaims(installations)...)
findings = append(findings, unknownArtifacts(installations, scan)...)
sort.Slice(installations, func(i, j int) bool {
return compareInstallations(installations[i], installations[j]) < 0
})
return installations, findings
}
// shadowVanillaClaims drops a generic vanilla entry-point claim when a
// specific installation claims the same artifact. Other vanilla entry points
// on the same installation remain observable.
func shadowVanillaClaims(installations []Installation) []Installation {
claims := make(map[string][]int, len(installations))
for index := range installations {
if !installations[index].Identity.IsValid() {
continue
}
for file := range installations[index].EntryPoints() {
claims[file.Path] = append(claims[file.Path], index)
}
}
shadowed := make(map[int]map[string]bool)
for path, indexes := range claims {
specific := false
for _, index := range indexes {
identity := installations[index].Identity
if !isVanillaIdentity(identity) && identity.IsValid() {
specific = true
break
}
}
if !specific {
continue
}
for _, index := range indexes {
if !isVanillaIdentity(installations[index].Identity) {
continue
}
if shadowed[index] == nil {
shadowed[index] = make(map[string]bool)
}
shadowed[index][path] = true
}
}
out := installations[:0]
for index := range installations {
for path := range shadowed[index] {
installations[index].removeFile(path)
}
if len(installations[index].Files) == 0 {
continue
}
out = append(out, installations[index])
}
clear(installations[len(out):])
return out
}
// mergeIdenticalInstallations unions members and entry points of equal
// concrete identities. Unknown or contradictory identities never merge.
func mergeIdenticalInstallations(installations []Installation) []Installation {
out := make([]Installation, 0, len(installations))
indexes := make(map[string]int, len(installations))
for index := range installations {
candidate := installations[index]
if candidate.Identity.IsValid() && candidate.Identity.IsConcrete() {
key := identityKey(candidate.Identity)
if existing, ok := indexes[key]; ok &&
out[existing].Identity.Equal(candidate.Identity) {
for _, file := range candidate.Files {
out[existing].addFile(file)
}
continue
}
indexes[key] = len(out)
}
out = append(out, candidate)
}
return out
}
// absorbVanillaPayloads treats a vanilla entry point referenced as a member by
// a matching-Minecraft non-vanilla installation as payload, not as a second
// standalone installation.
func absorbVanillaPayloads(installations []Installation) []Installation {
type reference struct {
installation int
path string
}
drop := make([]bool, len(installations))
for vanillaIndex := range installations {
vanilla := &installations[vanillaIndex]
if !vanilla.Identity.IsValid() || !isVanillaIdentity(vanilla.Identity) {
continue
}
minecraft := vanilla.Identity.MinecraftVersion()
if !concreteVersion(minecraft) {
continue
}
referenced := make(map[string]bool)
var references []reference
for file := range vanilla.EntryPoints() {
for installerIndex := range installations {
if installerIndex == vanillaIndex {
continue
}
installer := &installations[installerIndex]
if !installer.Identity.IsValid() ||
isVanillaIdentity(installer.Identity) ||
firstEntryPoint(installer) == nil {
continue
}
installerMinecraft := installer.Identity.MinecraftVersion()
if !concreteVersion(installerMinecraft) ||
installerMinecraft != minecraft ||
!hasFile(installer, file.Path) {
continue
}
references = append(references, reference{
installation: installerIndex,
path: file.Path,
})
referenced[file.Path] = true
}
}
if len(references) == 0 {
continue
}
for _, found := range references {
member := fileAt(vanilla, found.path)
if member == nil {
continue
}
installations[found.installation].addFile(File{
Path: member.Path,
Role: RolePayload,
Ownership: member.Ownership,
})
}
allReferenced := true
for file := range vanilla.EntryPoints() {
if !referenced[file.Path] {
allReferenced = false
break
}
}
if allReferenced {
for _, found := range references {
for _, file := range vanilla.Files {
role := file.Role
if role == RoleEntryPoint {
role = RolePayload
}
installations[found.installation].addFile(File{
Path: file.Path,
Role: role,
Ownership: file.Ownership,
})
}
}
drop[vanillaIndex] = true
continue
}
for path := range referenced {
vanilla.removeFile(path)
}
}
out := installations[:0]
for index := range installations {
if drop[index] {
continue
}
out = append(out, installations[index])
}
clear(installations[len(out):])
return out
}
func conflictingClaims(installations []Installation) []Finding {
claims := make(map[string][]*Installation, len(installations))
for index := range installations {
installation := &installations[index]
if !installation.Identity.IsValid() {
continue
}
for file := range installation.EntryPoints() {
claims[file.Path] = append(claims[file.Path], installation)
}
}
paths := make([]string, 0, len(claims))
for path := range claims {
paths = append(paths, path)
}
slices.Sort(paths)
findings := make([]Finding, 0)
for _, path := range paths {
found := claims[path]
if len(found) <= 1 || sameConcreteIdentity(found) {
continue
}
identities := make([]Identity, 0, len(found))
entryPoints := make([]string, 0, len(found))
for _, installation := range found {
identities = append(identities, installation.Identity)
for file := range installation.EntryPoints() {
entryPoints = append(entryPoints, file.Path)
}
}
findings = append(findings, Finding{
Kind: FindingConflictingIdentities,
Path: path,
Paths: sortedUnique(entryPoints),
Identities: uniqueIdentities(identities),
})
}
return findings
}
func sameConcreteIdentity(installations []*Installation) bool {
if len(installations) == 0 {
return true
}
first := installations[0].Identity
if !first.IsConcrete() {
return false
}
for _, installation := range installations[1:] {
if !installation.Identity.IsConcrete() ||
!first.Equal(installation.Identity) {
return false
}
}
return true
}
func unknownArtifacts(installations []Installation, scan *scan) []Finding {
if scan == nil {
return nil
}
claimed := make(map[string]bool)
for index := range installations {
for _, file := range installations[index].Files {
claimed[filepath.Clean(file.Path)] = true
}
}
var findings []Finding
for _, artifact := range scan.RootFiles() {
if artifact.IsDirectory() ||
!strings.HasSuffix(strings.ToLower(artifact.Path()), ".jar") ||
claimed[filepath.Clean(artifact.Path())] {
continue
}
findings = append(findings, Finding{
Kind: FindingUnknownArtifact,
Path: artifact.Path(),
})
}
return findings
}
func dropEmptyInstallations(installations []Installation) []Installation {
out := installations[:0]
for index := range installations {
if len(installations[index].Files) == 0 {
continue
}
out = append(out, installations[index])
}
clear(installations[len(out):])
return out
}
func compareInstallations(left, right Installation) int {
if result := compareIdentities(left.Identity, right.Identity); result != 0 {
return result
}
if result := strings.Compare(firstEntryPointPath(&left), firstEntryPointPath(&right)); result != 0 {
return result
}
return strings.Compare(firstFilePath(left), firstFilePath(right))
}
func firstFilePath(installation Installation) string {
if len(installation.Files) == 0 {
return ""
}
path := installation.Files[0].Path
for _, file := range installation.Files[1:] {
if file.Path < path {
path = file.Path
}
}
return path
}
func isVanillaIdentity(identity Identity) bool {
if !identity.IsValid() {
return false
}
return identity.Product() == server.ProductMinecraft
}
func hasFile(installation *Installation, path string) bool {
return fileAt(installation, path) != nil
}
func fileAt(installation *Installation, path string) *File {
if installation == nil {
return nil
}
path = filepath.Clean(path)
for index := range installation.Files {
if installation.Files[index].Path == path {
return &installation.Files[index]
}
}
return nil
}
package discovery
import (
"errors"
"fmt"
)
var errNotRegularFile = errors.New("discovery: artifact is not a regular file")
// SelectionError is the typed failure of a selection request. It carries the
// domain state and the available installations so the presentation boundary
// can derive its own guidance; no CLI command or severity strings live here.
type SelectionError struct {
State SelectionState
Path string
Installations []Installation
}
func (e *SelectionError) Error() string {
if e == nil {
return "server selection failed"
}
switch e.State {
case SelectionNone:
return "no server installation was found"
case SelectionAmbiguous:
return "multiple server installations satisfy the selection"
case SelectionMissingDeclared:
return "the declared server installation is not installed"
case SelectionInvalid:
if e.Path != "" {
return fmt.Sprintf(
"requested entry point %q is not a recognized server entry point",
e.Path,
)
}
return "no launchable server installation was found"
default:
return "server selection failed"
}
}
package discovery
import (
"archive/zip"
"bufio"
"encoding/json/v2"
"path/filepath"
"strings"
"github.com/mclucy/lucy/internal/fileschema"
versionpkg "github.com/mclucy/lucy/version"
)
const (
fabricInstallPropertiesPath = "install.properties"
fabricLaunchPropertiesPath = "fabric-server-launch.properties"
fabricMetaLauncherMainClass = "net.fabricmc.installer.ServerLauncher"
fabricServerDataDir = ".fabric/server"
fabricGeneratedLauncherPrefix = "fabric-loader-server-"
fabricVersionSidecarPath = "version.json"
// fabricLauncherPropertiesPath is the sidecar file FabricServerLauncher
// keeps beside the shim; note the extra "r" versus
// fabricLaunchPropertiesPath, which sits inside the jar.
fabricLauncherPropertiesPath = "fabric-server-launcher.properties"
fabricServerJarProperty = "serverJar"
minecraftServerJarName = "server.jar"
fabricModernKnotServer = "net.fabricmc.loader.impl.launch.knot.KnotServer"
fabricLegacyKnotServer = "net.fabricmc.loader.launch.knot.KnotServer"
)
// parseFabricInstallProperties reads the unattended install.properties entry
// that a Fabric meta launcher embeds. Only a complete loader and game version
// pair counts as a runtime identity.
func parseFabricInstallProperties(zipReader *zip.Reader) (
loaderVersion versionpkg.Version,
gameVersion versionpkg.Version,
ok bool,
) {
loaderVersion = versionpkg.Unknown
gameVersion = versionpkg.Unknown
for _, f := range zipReader.File {
if f.Name != fabricInstallPropertiesPath {
continue
}
r, err := f.Open()
if err != nil {
continue
}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if after, found := strings.CutPrefix(
line,
"fabric-loader-version=",
); found {
loaderVersion = versionpkg.Version(strings.TrimSpace(after))
} else if after, found := strings.CutPrefix(
line,
"game-version=",
); found {
gameVersion = versionpkg.Version(strings.TrimSpace(after))
}
}
scanErr := scanner.Err()
_ = r.Close()
if scanErr == nil && loaderVersion != versionpkg.Unknown &&
gameVersion != versionpkg.Unknown {
return loaderVersion, gameVersion, true
}
}
return loaderVersion, gameVersion, false
}
func fabricLauncherPropertiesAreServer(data []byte) bool {
for line := range strings.SplitSeq(string(data), "\n") {
line = strings.TrimRight(line, "\r")
if after, found := strings.CutPrefix(line, "launch.mainClass="); found {
return after == fabricModernKnotServer || after == fabricLegacyKnotServer
}
}
return false
}
// parseFabricLauncherManifest returns the loader and game versions named by
// the Class-Path entries of a Fabric launch jar manifest, together with the
// raw classpath. Installer shims keep library paths relative to the root;
// generated .fabric/server launchers walk back with "./../..".
func parseFabricLauncherManifest(zipReader *zip.Reader) (
loaderVersion versionpkg.Version,
gameVersion versionpkg.Version,
classPath []string,
) {
loaderVersion = versionpkg.Unknown
gameVersion = versionpkg.Unknown
classPath = parseArchiveClassPath(zipReader)
for _, entry := range classPath {
if parsed, found := fabricClassPathVersion(entry, "fabric-loader"); found {
loaderVersion = parsed
} else if parsed, found := fabricClassPathVersion(entry, "intermediary"); found {
gameVersion = parsed
}
}
return loaderVersion, gameVersion, classPath
}
// fabricClassPathVersion extracts the version segment after
// libraries/net/fabricmc/<artifact>/ in a manifest Class-Path entry.
func fabricClassPathVersion(classPath, artifact string) (versionpkg.Version, bool) {
segments := strings.Split(filepath.ToSlash(classPath), "/")
for i := 0; i+4 < len(segments); i++ {
if segments[i] != "libraries" || segments[i+1] != "net" ||
segments[i+2] != "fabricmc" || segments[i+3] != artifact {
continue
}
candidate := segments[i+4]
if candidate == "" || candidate == "." || candidate == ".." {
return versionpkg.Unknown, false
}
return versionpkg.Version(candidate), true
}
return versionpkg.Unknown, false
}
// readArchiveManifestMainClass returns the Main-Class attribute of a jar
// manifest, unfolding its continuation lines first.
func readArchiveManifestMainClass(zipReader *zip.Reader) (string, bool) {
data, ok, err := readArchiveEntry(zipReader, "META-INF/MANIFEST.MF")
if err != nil || !ok {
return "", false
}
for _, line := range unfoldManifestLines(data) {
if value, found := strings.CutPrefix(line, "Main-Class: "); found {
return strings.TrimSpace(value), true
}
}
return "", false
}
// parseArchiveClassPath returns the entries of the first Class-Path
// attribute in a jar manifest.
func parseArchiveClassPath(zipReader *zip.Reader) []string {
data, ok, err := readArchiveEntry(zipReader, "META-INF/MANIFEST.MF")
if err != nil || !ok {
return nil
}
for _, line := range unfoldManifestLines(data) {
value, found := strings.CutPrefix(line, "Class-Path: ")
if !found {
continue
}
return strings.Fields(value)
}
return nil
}
// unfoldManifestLines joins the continuation lines of a JAR manifest. A
// continuation line starts with a single space that is not part of the value.
func unfoldManifestLines(data []byte) []string {
raw := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
lines := make([]string, 0, len(raw))
for _, line := range raw {
line = strings.TrimSuffix(line, "\r")
if strings.HasPrefix(line, " ") && len(lines) > 0 {
lines[len(lines)-1] += line[1:]
continue
}
lines = append(lines, line)
}
return lines
}
func parseFabricLauncherBundledLoaderVersion(zipReader *zip.Reader) versionpkg.Version {
data, ok, err := readArchiveEntry(zipReader, "fabric.mod.json")
if err != nil || !ok {
return versionpkg.Unknown
}
var metadata fileschema.FileFabricModIdentifier
if err := json.Unmarshal(data, &metadata); err != nil {
return versionpkg.Unknown
}
if metadata.ID != "fabricloader" || strings.TrimSpace(metadata.Version) == "" {
return versionpkg.Unknown
}
return versionpkg.Version(metadata.Version)
}
func parseFabricLauncherSidecarGameVersion(
s *scan,
primaryFile *artifact,
) versionpkg.Version {
sidecar, ok := s.Sibling(primaryFile, fabricVersionSidecarPath)
if !ok || sidecar.IsDirectory() {
return versionpkg.Unknown
}
data, err := sidecar.Read()
if err != nil {
return versionpkg.Unknown
}
var metadata struct {
Name string `json:"name"`
ReleaseTarget string `json:"release_target"`
}
if err := json.Unmarshal(data, &metadata); err != nil {
return versionpkg.Unknown
}
if strings.TrimSpace(metadata.Name) != "" {
return versionpkg.Version(metadata.Name)
}
if strings.TrimSpace(metadata.ReleaseTarget) != "" {
return versionpkg.Version(metadata.ReleaseTarget)
}
return versionpkg.Unknown
}
// parseServerJarGameVersion reads the game version from the version.json of a
// server jar on disk. Legacy ids embed a build hash after " / "; the name and
// release_target fields carry the clean release id. Any problem yields
// VersionUnknown: this source is the last resort for launch shims with no
// embedded version evidence.
func parseServerJarGameVersion(file *artifact) versionpkg.Version {
zipReader, err := file.Archive()
if err != nil {
return versionpkg.Unknown
}
data, ok, err := readArchiveEntry(zipReader, mojangVersionJSONEntry)
if err != nil || !ok {
return versionpkg.Unknown
}
version, guarded, err := mojangVersionFromJSON(data)
if err != nil || guarded {
return versionpkg.Unknown
}
if isMinecraftReleaseVersion(string(version)) {
return version
}
var metadata struct {
Name string `json:"name"`
ReleaseTarget string `json:"release_target"`
}
if err := json.Unmarshal(data, &metadata); err != nil {
return versionpkg.Unknown
}
for _, candidate := range []string{
strings.TrimSpace(metadata.Name),
strings.TrimSpace(metadata.ReleaseTarget),
} {
if isMinecraftReleaseVersion(candidate) {
return versionpkg.Version(candidate)
}
}
return versionpkg.Unknown
}
// fabricLauncherServerJarPath returns the server jar that the launch shim at
// primaryPath boots. FabricServerLauncher reads serverJar from
// fabric-server-launcher.properties beside the shim and falls back to
// server.jar when the file or the entry is missing.
func fabricLauncherServerJarPath(
primaryPath string,
properties []byte,
) string {
name := minecraftServerJarName
if value := propertiesValue(properties, fabricServerJarProperty); value != "" {
name = value
}
if filepath.IsAbs(name) {
return filepath.Clean(name)
}
return filepath.Join(filepath.Dir(primaryPath), name)
}
// propertiesValue returns the value of key in a Java properties payload, or
// "" when the key is absent.
func propertiesValue(data []byte, key string) string {
var value string
for line := range strings.SplitSeq(string(data), "\n") {
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "!") {
continue
}
separator := strings.IndexAny(line, "=:\t ")
if separator < 0 {
continue
}
propertyKey := strings.TrimSpace(line[:separator])
if propertyKey != key {
continue
}
propertyValue := strings.TrimLeft(line[separator:], "=:\t ")
value = strings.TrimSpace(propertyValue)
}
return value
}
package discovery
import (
"encoding/json/v2"
"path/filepath"
"strings"
"github.com/mclucy/lucy/catalog"
versionpkg "github.com/mclucy/lucy/version"
)
// discoverFabric recognizes Fabric server distributions. Two layouts are
// legitimate:
//
// - an installer shim (fabric-server-launch.jar) beside the vanilla jar
// its fabric-server-launcher.properties names, with libraries referenced
// from the shim manifest;
// - an unattended meta launcher (fabric-server-mc.<mc>-loader.<loader>-
// launcher.<installer>.jar) which, after its first run, keeps a payload
// and generated launch jar under the hidden .fabric/server directory.
//
// The meta launcher is recognized before .fabric/server is materialized.
// Hidden files are claimed only when their content matches the identity the
// meta launcher embedded; stale files from another version stay unclaimed.
// A generated launcher without its meta launcher is still a real installed
// layout and is recognized from its own content.
func discoverFabric(s *scan) ([]Installation, []Finding) {
if s == nil {
return nil, nil
}
installations := make([]Installation, 0)
metaLauncherSeen := false
for _, file := range s.RootFiles() {
if file.IsDirectory() || !isJarArtifact(file) {
continue
}
if installation, ok := fabricMetaLauncherInstallation(s, file); ok {
installations = append(installations, installation)
metaLauncherSeen = true
continue
}
if installation, ok := fabricShimInstallation(s, file); ok {
installations = append(installations, installation)
}
}
if !metaLauncherSeen {
installations = append(
installations,
fabricGeneratedLauncherInstallations(s)...,
)
}
return installations, nil
}
// fabricMetaLauncherInstallation recognizes the unattended launcher by its
// embedded install.properties and claims the hidden files the launcher
// generates on first run when their content agrees with that identity.
func fabricMetaLauncherInstallation(
s *scan,
file *artifact,
) (Installation, bool) {
archive, err := file.Archive()
if err != nil {
return Installation{}, false
}
mainClass, ok := readArchiveManifestMainClass(archive)
if !ok || mainClass != fabricMetaLauncherMainClass {
return Installation{}, false
}
loaderVersion, gameVersion, ok := parseFabricInstallProperties(archive)
if !ok {
return Installation{}, false
}
installation := NewInstallation(
fabricIdentity(loaderVersion, gameVersion),
file.Path(),
)
dataDir := filepath.Join(s.root, filepath.FromSlash(fabricServerDataDir))
generatedPath := filepath.Join(
dataDir,
fabricGeneratedLauncherPrefix+string(loaderVersion)+
"-minecraft-"+string(gameVersion)+".jar",
)
if generated, ok := s.File(generatedPath); ok && !generated.IsDirectory() {
generatedLoader, generatedGame, classPath, ok := fabricLaunchJarIdentity(generated)
if ok && generatedLoader == loaderVersion && generatedGame == gameVersion {
installation.AddFile(generated.Path(), RoleEntryPoint)
addLocalClassPathFiles(s, &installation, generated, classPath)
addFabricSidecars(s, &installation, generated)
}
}
payloadPath := filepath.Join(dataDir, string(gameVersion)+"-server.jar")
if payload, ok := s.File(payloadPath); ok && !payload.IsDirectory() &&
fabricServerPayloadMatches(payload, gameVersion) {
installation.AddFile(payload.Path(), RolePayload)
}
return installation, true
}
// fabricShimInstallation enriches a launch shim with the sidecars, payload
// and classpath files the shim needs at run time.
func fabricShimInstallation(s *scan, file *artifact) (Installation, bool) {
archive, err := file.Archive()
if err != nil {
return Installation{}, false
}
properties, ok, err := readArchiveEntry(archive, fabricLaunchPropertiesPath)
if err != nil || !ok || !fabricLauncherPropertiesAreServer(properties) {
return Installation{}, false
}
loaderVersion, gameVersion, classPath := parseFabricLauncherManifest(archive)
if loaderVersion == versionpkg.Unknown {
loaderVersion = parseFabricLauncherBundledLoaderVersion(archive)
}
launcherProperties := []byte(nil)
propertiesFile, hasProperties := s.Sibling(file, fabricLauncherPropertiesPath)
if hasProperties && !propertiesFile.IsDirectory() {
launcherProperties, _ = propertiesFile.Read()
}
if gameVersion == versionpkg.Unknown {
gameVersion = parseFabricLauncherSidecarGameVersion(s, file)
}
// Installer 1.1+ shims embed no version evidence, so the last resort is
// the vanilla jar the shim boots: FabricServerLauncher reads serverJar
// from fabric-server-launcher.properties and defaults to server.jar.
serverJarPath := fabricLauncherServerJarPath(file.Path(), launcherProperties)
serverJar, hasServerJar := s.File(serverJarPath)
if hasServerJar && serverJar.IsDirectory() {
hasServerJar = false
}
if gameVersion == versionpkg.Unknown && hasServerJar {
gameVersion = parseServerJarGameVersion(serverJar)
}
if loaderVersion == versionpkg.Unknown || gameVersion == versionpkg.Unknown {
return Installation{}, false
}
installation := NewInstallation(
fabricIdentity(loaderVersion, gameVersion),
file.Path(),
)
if hasServerJar {
installation.AddFile(serverJar.Path(), RolePayload)
}
if hasProperties {
installation.AddFile(propertiesFile.Path(), RoleConfiguration)
}
if sidecar, ok := s.Sibling(file, fabricVersionSidecarPath); ok &&
!sidecar.IsDirectory() {
installation.AddFile(sidecar.Path(), RoleConfiguration)
}
addLocalClassPathFiles(s, &installation, file, classPath)
return installation, true
}
// fabricGeneratedLauncherInstallations recognizes a generated launch jar in
// the hidden server data directory when no meta launcher remains.
func fabricGeneratedLauncherInstallations(s *scan) []Installation {
dataDir := filepath.Join(s.root, filepath.FromSlash(fabricServerDataDir))
entries, err := s.ReadDir(dataDir)
if err != nil {
return nil
}
installations := make([]Installation, 0)
for _, entry := range entries {
if entry.IsDir() ||
!strings.HasPrefix(entry.Name(), fabricGeneratedLauncherPrefix) ||
!strings.HasSuffix(strings.ToLower(entry.Name()), ".jar") {
continue
}
file, ok := s.File(filepath.Join(dataDir, entry.Name()))
if !ok || file.IsDirectory() {
continue
}
loaderVersion, gameVersion, classPath, ok := fabricLaunchJarIdentity(file)
if !ok {
continue
}
installation := NewInstallation(
fabricIdentity(loaderVersion, gameVersion),
file.Path(),
)
addLocalClassPathFiles(s, &installation, file, classPath)
addFabricSidecars(s, &installation, file)
payloadPath := filepath.Join(dataDir, string(gameVersion)+"-server.jar")
if payload, ok := s.File(payloadPath); ok && !payload.IsDirectory() &&
fabricServerPayloadMatches(payload, gameVersion) {
installation.AddFile(payload.Path(), RolePayload)
}
installations = append(installations, installation)
}
return installations
}
// addFabricSidecars claims the launcher properties and version metadata that
// sit beside a generated launch jar.
func addFabricSidecars(s *scan, installation *Installation, launcher *artifact) {
if properties, ok := s.Sibling(launcher, fabricLauncherPropertiesPath); ok &&
!properties.IsDirectory() {
installation.AddFile(properties.Path(), RoleConfiguration)
}
if sidecar, ok := s.Sibling(launcher, fabricVersionSidecarPath); ok &&
!sidecar.IsDirectory() {
installation.AddFile(sidecar.Path(), RoleConfiguration)
}
}
// fabricServerPayloadMatches verifies that a downloaded server jar carries
// the game version the meta launcher embedded. The id field is authoritative
// for modern servers; name and release_target cover older ids that append a
// build hash.
func fabricServerPayloadMatches(
file *artifact,
gameVersion versionpkg.Version,
) bool {
archive, err := file.Archive()
if err != nil {
return false
}
data, ok, err := readArchiveEntry(archive, mojangVersionJSONEntry)
if err != nil || !ok {
return false
}
version, guarded, err := mojangVersionFromJSON(data)
if err != nil || guarded {
return false
}
if version == gameVersion {
return true
}
var metadata struct {
Name string `json:"name"`
ReleaseTarget string `json:"release_target"`
}
if err := json.Unmarshal(data, &metadata); err != nil {
return false
}
return versionpkg.Version(strings.TrimSpace(metadata.Name)) == gameVersion ||
versionpkg.Version(strings.TrimSpace(metadata.ReleaseTarget)) == gameVersion
}
// fabricLaunchJarIdentity validates a generated Fabric launch jar from its
// content. The filename alone is never sufficient.
func fabricLaunchJarIdentity(file *artifact) (
loaderVersion versionpkg.Version,
gameVersion versionpkg.Version,
classPath []string,
ok bool,
) {
archive, err := file.Archive()
if err != nil {
return versionpkg.Unknown, versionpkg.Unknown, nil, false
}
loaderVersion, gameVersion, classPath = parseFabricLauncherManifest(archive)
if loaderVersion == versionpkg.Unknown || gameVersion == versionpkg.Unknown {
return versionpkg.Unknown, versionpkg.Unknown, nil, false
}
properties, found, err := readArchiveEntry(archive, fabricLaunchPropertiesPath)
if err != nil || !found || !fabricLauncherPropertiesAreServer(properties) {
return versionpkg.Unknown, versionpkg.Unknown, nil, false
}
return loaderVersion, gameVersion, classPath, true
}
// addMatchingMinecraftPayload claims the root Minecraft server jar as the
// installation payload when its content names the same game version. The
// engine then treats the matching vanilla installation as payload rather
// than a second server.
func addMatchingMinecraftPayload(
s *scan,
installation *Installation,
gameVersion versionpkg.Version,
) {
if s == nil || installation == nil || !hasConcreteVersion(gameVersion) {
return
}
serverJar, ok := s.File(filepath.Join(s.root, minecraftServerJarName))
if !ok || serverJar.IsDirectory() {
return
}
if parseServerJarGameVersion(serverJar) != gameVersion {
return
}
installation.AddFile(serverJar.Path(), RolePayload)
}
// addMinecraftServerLibraryPayload claims the Minecraft server jar that
// installers keep under libraries/net/minecraft/server/<mc>/. The path names
// the game version; a version.json inside the jar must agree when present.
func addMinecraftServerLibraryPayload(
s *scan,
installation *Installation,
gameVersion versionpkg.Version,
) {
if s == nil || installation == nil || !hasConcreteVersion(gameVersion) {
return
}
version := string(gameVersion)
for _, name := range []string{
"server-" + version + ".jar",
"server-" + version + "-bundled.jar",
} {
path := filepath.Join(
s.root,
"libraries",
"net",
"minecraft",
"server",
version,
name,
)
file, ok := s.File(path)
if !ok || file.IsDirectory() {
continue
}
if recorded := parseServerJarGameVersion(file); recorded != versionpkg.Unknown &&
recorded != gameVersion {
continue
}
installation.AddFile(file.Path(), RolePayload)
}
}
// addLocalClassPathFiles resolves manifest Class-Path entries against the jar
// that declares them and claims the local files that exist. Class-Path
// entries must stay inside the scanned directory.
func addLocalClassPathFiles(
s *scan,
installation *Installation,
jar *artifact,
classPath []string,
) {
for _, entry := range classPath {
entry = strings.Trim(strings.TrimSpace(entry), `"`)
if entry == "" || strings.Contains(entry, "://") ||
strings.HasPrefix(entry, "file:") {
continue
}
path := filepath.FromSlash(entry)
if !filepath.IsAbs(path) {
path = filepath.Join(filepath.Dir(jar.Path()), path)
}
if file, ok := s.File(path); ok && !file.IsDirectory() {
installation.AddFile(file.Path(), RoleLibrary)
}
}
}
func fabricIdentity(
loaderVersion versionpkg.Version,
gameVersion versionpkg.Version,
) Identity {
return Identity{
Primary: catalog.VersionedRef{
Ecosystem: catalog.EcosystemFabric,
Name: "fabric",
Version: loaderVersion,
},
Components: []catalog.VersionedRef{
{
Ecosystem: catalog.EcosystemFabric,
Name: "fabric-loader",
Version: loaderVersion,
},
{
Ecosystem: catalog.EcosystemMinecraft,
Name: "minecraft",
Version: gameVersion,
},
},
}
}
// isJarArtifact reports whether a scanned file is a jar by name. Content
// inspection decides whether it is a real installation; the name only avoids
// opening unrelated files.
func isJarArtifact(file *artifact) bool {
return strings.HasSuffix(strings.ToLower(file.Path()), ".jar")
}
package discovery
import (
"iter"
"slices"
"sort"
)
// FindingKind is a typed observation condition. Presentation severity and CLI
// suggestions belong to the presentation boundary.
type FindingKind string
const (
FindingUnknown FindingKind = ""
FindingUnknownArtifact FindingKind = "unknown_artifact"
FindingConflictingIdentities FindingKind = "conflicting_identities"
FindingInvalidIdentity FindingKind = "invalid_identity"
FindingUnreadableFile FindingKind = "unreadable_file"
FindingReceiptError FindingKind = "receipt_error"
FindingOwnershipError FindingKind = "ownership_error"
FindingModifiedFile FindingKind = "modified_file"
FindingAlternativeEntryPoint FindingKind = "alternative_entry_point"
FindingUnmanagedInstallation FindingKind = "unmanaged_installation"
FindingMissingDeclaredInstallation FindingKind = "missing_declared_installation"
FindingAmbiguousInstallations FindingKind = "ambiguous_installations"
FindingAutomaticSelection FindingKind = "automatic_selection"
)
// Finding is one typed observation condition. Path is the primary affected
// path; Paths carries every competing or related path when the condition spans
// several entry points.
type Finding struct {
Kind FindingKind
Path string
Paths []string
Identity Identity
Identities []Identity
Cause error
}
// Result is one discovery observation. Installations are immutable after
// Discover except for ownership labels applied by ObserveOwnership. Selection
// is the explicit read-only choice made by Select.
type Result struct {
Installations []Installation
Diagnostics []Finding
Selection Selection
}
// Selected returns the current selection only when it names a selected
// installation and entry point.
func (r *Result) Selected() *Selection {
if r == nil || !r.Selection.IsValid() {
return nil
}
return &r.Selection
}
// Findings yields the immutable diagnostics followed by selection conditions
// derived from the current selection. Nothing is cached between calls, so a
// changed selection never leaves stale finding arrays behind.
func (r *Result) Findings() iter.Seq[Finding] {
return func(yield func(Finding) bool) {
if r == nil {
return
}
for _, finding := range r.Diagnostics {
if !yield(finding) {
return
}
}
r.yieldSelectionFindings(yield)
}
}
func (r *Result) yieldSelectionFindings(yield func(Finding) bool) bool {
switch r.Selection.State {
case SelectionSelected:
return r.yieldSelectedFindings(yield)
case SelectionAmbiguous:
return yield(Finding{
Kind: FindingAmbiguousInstallations,
Path: r.Selection.RequestedPath,
Paths: r.candidateEntryPoints(),
Identities: r.candidateIdentities(),
})
case SelectionMissingDeclared:
return yield(Finding{
Kind: FindingMissingDeclaredInstallation,
})
default:
return true
}
}
func (r *Result) yieldSelectedFindings(yield func(Finding) bool) bool {
selected := r.Selection
if !selected.IsValid() {
return true
}
if selected.Source == SourcePreferred {
if !yield(Finding{
Kind: FindingAutomaticSelection,
Path: selected.EntryPoint.Path,
Paths: r.competingEntryPoints(),
Identity: selected.Installation.Identity,
Identities: r.competingIdentities(),
}) {
return false
}
}
for file := range selected.Installation.EntryPoints() {
if file.Path == selected.EntryPoint.Path ||
file.Ownership == OwnershipManaged {
continue
}
if !yield(Finding{
Kind: FindingAlternativeEntryPoint,
Path: file.Path,
Identity: selected.Installation.Identity,
}) {
return false
}
}
for index := range r.Installations {
installation := &r.Installations[index]
if installation == selected.Installation ||
!selectableInstallation(installation) {
continue
}
for file := range installation.EntryPoints() {
if file.Ownership == OwnershipManaged {
continue
}
if !yield(Finding{
Kind: FindingUnmanagedInstallation,
Path: file.Path,
Identity: installation.Identity,
}) {
return false
}
}
}
return true
}
func (r *Result) candidateEntryPoints() []string {
paths := make([]string, 0, len(r.Installations))
for index := range r.Installations {
installation := &r.Installations[index]
if !selectableInstallation(installation) {
continue
}
for file := range installation.EntryPoints() {
paths = append(paths, file.Path)
}
}
return sortedUnique(paths)
}
func (r *Result) candidateIdentities() []Identity {
identities := make([]Identity, 0, len(r.Installations))
for index := range r.Installations {
installation := &r.Installations[index]
if !selectableInstallation(installation) {
continue
}
identities = append(identities, installation.Identity)
}
return uniqueIdentities(identities)
}
func (r *Result) competingEntryPoints() []string {
if r == nil || r.Selection.Installation == nil {
return nil
}
selectedPath := ""
if r.Selection.EntryPoint != nil {
selectedPath = r.Selection.EntryPoint.Path
}
paths := make([]string, 0, len(r.Installations))
for index := range r.Installations {
installation := &r.Installations[index]
if installation == r.Selection.Installation ||
!selectableInstallation(installation) {
continue
}
for file := range installation.EntryPoints() {
if file.Path == selectedPath {
continue
}
paths = append(paths, file.Path)
}
}
return sortedUnique(paths)
}
func (r *Result) competingIdentities() []Identity {
if r == nil || r.Selection.Installation == nil {
return nil
}
identities := make([]Identity, 0, len(r.Installations))
for index := range r.Installations {
installation := &r.Installations[index]
if installation == r.Selection.Installation ||
!selectableInstallation(installation) {
continue
}
identities = append(identities, installation.Identity)
}
return uniqueIdentities(identities)
}
func selectableInstallation(installation *Installation) bool {
return installation != nil &&
installation.Identity.IsValid() &&
firstEntryPoint(installation) != nil
}
// uniqueIdentities sorts the caller-owned slice and compacts equal canonical
// identities in place.
func uniqueIdentities(identities []Identity) []Identity {
sort.SliceStable(identities, func(i, j int) bool {
return compareIdentities(identities[i], identities[j]) < 0
})
unique := identities[:0]
last := ""
for _, identity := range identities {
key := identityKey(identity)
if len(unique) > 0 && key == last {
continue
}
last = key
unique = append(unique, identity)
}
clear(identities[len(unique):])
return unique
}
func sortedUnique(paths []string) []string {
if len(paths) == 0 {
return nil
}
slices.Sort(paths)
unique := paths[:0]
for _, path := range paths {
if len(unique) > 0 && unique[len(unique)-1] == path {
continue
}
unique = append(unique, path)
}
return unique
}
func nonEmptyPaths(path string) []string {
if path == "" {
return nil
}
return []string{path}
}
package discovery
import (
"path/filepath"
"strings"
"github.com/mclucy/lucy/catalog"
versionpkg "github.com/mclucy/lucy/version"
)
const forgeLibraryRoot = "libraries/net/minecraftforge/forge"
// discoverForge recognizes installed Forge servers under the Forge Maven
// library directory. One directory holds at most one Forge installation:
// modern layouts keep the runtime in a universal library and launch it
// through a generated argument file or a bootstrap shim; legacy layouts use
// the universal jar directly. The installer jar itself is a provisioning
// tool and is never a runtime.
func discoverForge(s *scan) ([]Installation, []Finding) {
if s == nil {
return nil, nil
}
root := filepath.Join(s.root, filepath.FromSlash(forgeLibraryRoot))
installations := make([]Installation, 0)
if entries, err := s.ReadDir(root); err == nil {
for _, entry := range entries {
if !entry.IsDir() {
continue
}
installation, ok := forgeInstallation(
s,
filepath.Join(root, entry.Name()),
)
if ok {
installations = append(installations, installation)
}
}
}
installations = append(
installations,
discoverRootForgeInstallations(s)...,
)
return installations, nil
}
// discoverRootForgeInstallations recognizes standalone Forge launchers kept
// directly in the server directory. These are the legacy universal and server
// jars that predate the Maven-shaped installed layout. Installers and shims
// are provisioning/launch fragments, not standalone installations.
func discoverRootForgeInstallations(s *scan) []Installation {
installations := make([]Installation, 0)
for _, file := range s.RootFiles() {
if file.IsDirectory() || !isJarArtifact(file) {
continue
}
installation, ok := rootForgeInstallation(s, file)
if ok {
installations = append(installations, installation)
}
}
return installations
}
// rootForgeInstallation builds one installation from a root-level Forge
// launcher. Identity comes from manifest metadata, the Maven-shaped filename,
// or validated argument sidecars. A jar without positive Forge content is
// never claimed, even when it has a Main-Class.
func rootForgeInstallation(
s *scan,
file *artifact,
) (Installation, bool) {
archive, err := file.Archive()
if err != nil {
return Installation{}, false
}
if verifyForgeShimArtifact(file) {
return Installation{}, false
}
if installerArtifact(archive) {
return Installation{}, false
}
manifestForge, manifestGame := parseForgeManifest(archive)
tupleGame, tupleForge, _ := parseForgeVersionTupleFromPath(file.Path())
unixArgs, hasUnixArgs := s.Sibling(file, "unix_args.txt")
winArgs, hasWinArgs := s.Sibling(file, "win_args.txt")
argsLoader, argsGame := versionpkg.Unknown, versionpkg.Unknown
if hasUnixArgs {
if data, err := unixArgs.Read(); err == nil {
argsLoader, argsGame = parseLoaderArgs(data)
}
}
if !hasConcreteVersion(argsGame) && hasWinArgs {
if data, err := winArgs.Read(); err == nil {
argsLoader, argsGame = parseLoaderArgs(data)
}
}
gameVersion := firstConcreteVersion(manifestGame, tupleGame, argsGame)
forgeVersion := firstConcreteVersion(manifestForge, tupleForge, argsLoader)
if !hasConcreteVersion(gameVersion) || !hasConcreteVersion(forgeVersion) {
return Installation{}, false
}
unixArgsValid := hasUnixArgs &&
loaderArgsLaunchValid(unixArgs, forgeVersion, gameVersion)
winArgsValid := hasWinArgs &&
loaderArgsLaunchValid(winArgs, forgeVersion, gameVersion)
base := filepath.Base(file.Path())
kind := forgeArtifactKindFromName(base)
universalSibling, hasUniversalSibling := forgeRootSibling(s, file, base, "universal")
serverSibling, hasServerSibling := forgeRootSibling(s, file, base, "server")
shim, hasShim := forgeRootSibling(s, file, base, "shim")
universalValid := kind == "universal" &&
verifyForgeUniversalArtifact(file, forgeVersion, gameVersion)
if !universalValid && hasUniversalSibling {
universalValid = verifyForgeUniversalArtifact(
universalSibling,
forgeVersion,
gameVersion,
)
}
serverReadable := (kind == "server" && forgeArtifactReadable(file)) ||
(hasServerSibling && forgeArtifactReadable(serverSibling))
fileReadable := forgeArtifactReadable(file)
shimValid := hasShim && verifyForgeShimArtifact(shim)
runnable := artifactHasLaunchMainClass(file)
manifestMatches := hasConcreteVersion(manifestForge) &&
manifestForge == forgeVersion &&
(!hasConcreteVersion(manifestGame) || manifestGame == gameVersion)
productContent := manifestMatches || forgeProductContent(archive)
runtimePresent := universalValid || serverReadable || fileReadable
entries := make([]*artifact, 0, 3)
switch {
case shimValid && runtimePresent:
entries = append(entries, shim)
if unixArgsValid {
entries = append(entries, unixArgs)
}
if winArgsValid {
entries = append(entries, winArgs)
}
case (unixArgsValid || winArgsValid) && runtimePresent:
if unixArgsValid {
entries = append(entries, unixArgs)
}
if winArgsValid {
entries = append(entries, winArgs)
}
case runnable && productContent:
entries = append(entries, file)
default:
return Installation{}, false
}
installation := NewInstallation(
forgeIdentity(gameVersion, forgeVersion),
entries[0].Path(),
)
for _, entry := range entries[1:] {
installation.AddFile(entry.Path(), RoleEntryPoint)
}
universalArtifact := universalSibling
if kind == "universal" {
universalArtifact = file
}
serverArtifact := serverSibling
if kind == "server" {
serverArtifact = file
}
if universalValid && universalArtifact != nil &&
!containsArtifact(entries, universalArtifact) {
installation.AddFile(universalArtifact.Path(), RoleLibrary)
}
if serverReadable && serverArtifact != nil &&
!containsArtifact(entries, serverArtifact) {
installation.AddFile(serverArtifact.Path(), RolePayload)
}
if !containsArtifact(entries, file) && kind == "" && fileReadable {
installation.AddFile(file.Path(), RolePayload)
}
if hasUnixArgs && !unixArgsValid {
installation.AddFile(unixArgs.Path(), RoleConfiguration)
}
if hasWinArgs && !winArgsValid {
installation.AddFile(winArgs.Path(), RoleConfiguration)
}
if shimValid {
addForgeClassPathFiles(s, &installation, shim)
}
addForgeRootClassPathFiles(s, &installation, file, gameVersion)
addMatchingMinecraftPayload(s, &installation, gameVersion)
addMinecraftServerLibraryPayload(s, &installation, gameVersion)
return installation, true
}
// addForgeRootClassPathFiles claims the local libraries and Minecraft server
// payload named by a root Forge launcher manifest. Legacy universals list the
// Minecraft server jar directly in Class-Path.
func addForgeRootClassPathFiles(
s *scan,
installation *Installation,
jar *artifact,
gameVersion versionpkg.Version,
) {
for _, entry := range parseArchiveClassPathFromArtifact(jar) {
entry = strings.Trim(strings.TrimSpace(entry), `"`)
if entry == "" || strings.Contains(entry, "://") ||
strings.HasPrefix(entry, "file:") {
continue
}
path := filepath.FromSlash(entry)
candidates := []string{filepath.Join(s.root, path)}
if filepath.IsAbs(path) {
candidates = []string{path}
} else {
candidates = append(
candidates,
filepath.Join(filepath.Dir(jar.Path()), path),
)
}
for _, candidate := range candidates {
file, ok := s.File(candidate)
if !ok || file.IsDirectory() {
continue
}
role := RoleLibrary
base := strings.ToLower(filepath.Base(file.Path()))
if strings.Contains(base, "minecraft_server") ||
(strings.HasPrefix(base, "server-") &&
strings.HasSuffix(base, ".jar")) {
if recorded := parseServerJarGameVersion(file); recorded == versionpkg.Unknown ||
recorded == gameVersion {
role = RolePayload
}
}
installation.AddFile(file.Path(), role)
break
}
}
}
func forgeRootSibling(
s *scan,
file *artifact,
base string,
kind string,
) (*artifact, bool) {
prefix := forgeArtifactNamePrefix(base)
if prefix == "" {
return nil, false
}
sibling, ok := s.Sibling(file, prefix+"-"+kind+".jar")
if !ok || sibling.IsDirectory() {
return nil, false
}
return sibling, true
}
func forgeArtifactNamePrefix(base string) string {
for _, kind := range []string{"server", "universal", "shim"} {
suffix := "-" + kind + ".jar"
if strings.HasSuffix(base, suffix) {
return strings.TrimSuffix(base, suffix)
}
}
return ""
}
func forgeArtifactKindFromName(base string) string {
for _, kind := range []string{"server", "universal", "shim"} {
if strings.HasSuffix(base, "-"+kind+".jar") {
return kind
}
}
return ""
}
func firstConcreteVersion(values ...versionpkg.Version) versionpkg.Version {
for _, value := range values {
if hasConcreteVersion(value) {
return value
}
}
return versionpkg.Unknown
}
// forgeInstallation builds one installation from a
// libraries/net/minecraftforge/forge/<minecraft>-<forge>/ directory. A shim
// is the real entry point when it is complete; a server jar with generated
// arguments is the next choice; the universal jar is the entry point only
// for layouts that predate both, and is otherwise a runtime library.
func forgeInstallation(s *scan, versionDir string) (Installation, bool) {
version := filepath.Base(versionDir)
match := forgeRuntimeVersionDirPattern.FindStringSubmatch(version)
if match == nil {
return Installation{}, false
}
gameVersion := versionpkg.Version(match[1])
forgeVersion := versionpkg.Version(match[2])
universal, hasUniversal := forgeArtifact(
s,
versionDir,
"forge-"+version+"-universal.jar",
)
server, hasServer := forgeArtifact(
s,
versionDir,
"forge-"+version+"-server.jar",
)
shim, hasShim := forgeArtifact(
s,
versionDir,
"forge-"+version+"-shim.jar",
)
unixArgs, hasUnixArgs := forgeArtifact(s, versionDir, "unix_args.txt")
winArgs, hasWinArgs := forgeArtifact(s, versionDir, "win_args.txt")
shimValid := hasShim && verifyForgeShimArtifact(shim)
serverReadable := hasServer && forgeArtifactReadable(server)
universalValid := hasUniversal &&
verifyForgeUniversalArtifact(universal, forgeVersion, gameVersion)
unixArgsValid := hasUnixArgs &&
loaderArgsLaunchValid(unixArgs, forgeVersion, gameVersion)
winArgsValid := hasWinArgs &&
loaderArgsLaunchValid(winArgs, forgeVersion, gameVersion)
serverRunnable := serverReadable && artifactHasLaunchMainClass(server) &&
verifyForgeServerArtifact(server, forgeVersion, gameVersion)
universalRunnable := universalValid && artifactHasLaunchMainClass(universal)
entries := make([]*artifact, 0, 3)
switch {
case shimValid && (universalValid || serverReadable):
// The shim is the direct java -jar launch target, but it only
// bootstraps a complete layout. Generated arguments remain launch
// targets as well when they validate.
entries = append(entries, shim)
if unixArgsValid {
entries = append(entries, unixArgs)
}
if winArgsValid {
entries = append(entries, winArgs)
}
case (unixArgsValid || winArgsValid) && (universalValid || serverReadable):
if unixArgsValid {
entries = append(entries, unixArgs)
}
if winArgsValid {
entries = append(entries, winArgs)
}
case serverRunnable:
entries = append(entries, server)
case universalRunnable:
// Only genuine legacy universal jars with their own launch entry
// class are bootable; otherwise the universal is a runtime library.
entries = append(entries, universal)
default:
// An installer, a library-only universal, or a bare argument file
// is not a runnable layout.
return Installation{}, false
}
installation := NewInstallation(
forgeIdentity(gameVersion, forgeVersion),
entries[0].Path(),
)
for _, entry := range entries[1:] {
installation.AddFile(entry.Path(), RoleEntryPoint)
}
if universalValid && !containsArtifact(entries, universal) {
installation.AddFile(universal.Path(), RoleLibrary)
}
if serverReadable && !containsArtifact(entries, server) {
installation.AddFile(server.Path(), RolePayload)
}
if hasUnixArgs && !unixArgsValid {
installation.AddFile(unixArgs.Path(), RoleConfiguration)
}
if hasWinArgs && !winArgsValid {
installation.AddFile(winArgs.Path(), RoleConfiguration)
}
if shimValid {
addForgeClassPathFiles(s, &installation, shim)
}
addMatchingMinecraftPayload(s, &installation, gameVersion)
addMinecraftServerLibraryPayload(s, &installation, gameVersion)
return installation, true
}
func containsArtifact(artifacts []*artifact, target *artifact) bool {
for _, artifact := range artifacts {
if artifact == target {
return true
}
}
return false
}
// addForgeClassPathFiles claims the libraries named by a Forge shim
// manifest. Forge writes those entries relative to the server root, so the
// root is tried before the shim's own directory.
func addForgeClassPathFiles(
s *scan,
installation *Installation,
shim *artifact,
) {
for _, entry := range parseArchiveClassPathFromArtifact(shim) {
entry = strings.Trim(strings.TrimSpace(entry), `"`)
if entry == "" || strings.Contains(entry, "://") ||
strings.HasPrefix(entry, "file:") {
continue
}
path := filepath.FromSlash(entry)
candidates := []string{filepath.Join(s.root, path)}
if filepath.IsAbs(path) {
candidates = []string{path}
} else {
candidates = append(
candidates,
filepath.Join(filepath.Dir(shim.Path()), path),
)
}
for _, candidate := range candidates {
file, ok := s.File(candidate)
if !ok || file.IsDirectory() {
continue
}
installation.AddFile(file.Path(), RoleLibrary)
break
}
}
}
func forgeArtifact(
s *scan,
versionDir string,
name string,
) (*artifact, bool) {
file, ok := s.File(filepath.Join(versionDir, name))
if !ok || file.IsDirectory() {
return nil, false
}
return file, true
}
// forgeArtifactReadable reports whether the artifact is a readable archive.
func forgeArtifactReadable(file *artifact) bool {
_, err := file.Archive()
return err == nil
}
func parseArchiveClassPathFromArtifact(file *artifact) []string {
archive, err := file.Archive()
if err != nil {
return nil
}
return parseArchiveClassPath(archive)
}
func forgeIdentity(
gameVersion versionpkg.Version,
forgeVersion versionpkg.Version,
) Identity {
return Identity{
Primary: catalog.VersionedRef{
Ecosystem: catalog.EcosystemForge,
Name: "forge",
Version: forgeVersion,
},
Components: []catalog.VersionedRef{
{
Ecosystem: catalog.EcosystemForge,
Name: "forge",
Version: forgeVersion,
},
{
Ecosystem: catalog.EcosystemMinecraft,
Name: "minecraft",
Version: gameVersion,
},
},
}
}
package discovery
import (
"archive/zip"
"strings"
versionpkg "github.com/mclucy/lucy/version"
)
// loaderArgsLaunchValid reports whether a generated argument sidecar is a
// positively identified launch target for this loader layout: it names the
// same loader and Minecraft versions and either a server launch target or a
// server main class. NeoForge 26.2 dropped --launchTarget in favor of
// net.neoforged.fml.startup.Server.
func loaderArgsLaunchValid(
file *artifact,
loaderVersion versionpkg.Version,
gameVersion versionpkg.Version,
) bool {
if file == nil || !hasConcreteVersion(gameVersion) {
return false
}
data, err := file.Read()
if err != nil {
return false
}
argsLoader, argsGame := parseLoaderArgs(data)
if hasConcreteVersion(argsLoader) && argsLoader != loaderVersion {
return false
}
if !hasConcreteVersion(argsGame) || argsGame != gameVersion {
return false
}
target := strings.ToLower(parseLaunchTarget(data))
if target != "" && strings.Contains(target, "server") {
return true
}
mainClass := strings.ToLower(parseLaunchMainClass(data))
return mainClass != "" && strings.Contains(mainClass, "server")
}
// parseLaunchMainClass returns the first fully qualified Java class token in a
// generated argument sidecar. Flags, JVM properties, and classpath entries are
// not class tokens.
func parseLaunchMainClass(data []byte) string {
for line := range strings.SplitSeq(string(data), "\n") {
candidate := strings.TrimSpace(line)
if candidate == "" || strings.HasPrefix(candidate, "-") ||
strings.ContainsAny(candidate, " \t=/:") {
continue
}
if isJavaClassName(candidate) {
return candidate
}
}
return ""
}
func isJavaClassName(value string) bool {
parts := strings.Split(value, ".")
if len(parts) < 2 {
return false
}
for _, part := range parts {
if part == "" {
return false
}
for index, r := range part {
switch {
case r == '_' || r == '$':
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case index > 0 && r >= '0' && r <= '9':
default:
return false
}
}
}
last := parts[len(parts)-1]
return last[0] >= 'A' && last[0] <= 'Z'
}
// artifactHasLaunchMainClass reports whether a jar declares a Main-Class and
// is therefore independently java -jar runnable.
func artifactHasLaunchMainClass(file *artifact) bool {
if file == nil {
return false
}
archive, err := file.Archive()
if err != nil {
return false
}
mainClass, ok := readArchiveManifestMainClass(archive)
return ok && strings.TrimSpace(mainClass) != ""
}
// forgeProductContent reports whether a jar carries positive Forge content.
// Legacy universal jars have no implementation versions in their manifest;
// they identify themselves through Tweak-Class, a Forge main class, or FML
// classes.
func forgeProductContent(zipReader *zip.Reader) bool {
switch {
case archiveManifestContains(zipReader, "FML-System-Mods: forge"):
return true
case archiveManifestContains(zipReader, "Tweak-Class: net.minecraftforge."):
return true
case archiveManifestContains(zipReader, "Main-Class: net.minecraftforge."):
return true
case archiveContains(
zipReader,
"net/minecraftforge/fml/relauncher/ServerLaunchWrapper.class",
):
return true
case archiveContains(
zipReader,
"net/minecraftforge/fml/common/launcher/FMLTweaker.class",
):
return true
default:
return false
}
}
// verifyForgeUniversalArtifact validates a universal jar by the Forge and
// Minecraft versions recorded in its manifest. The directory name supplies
// the Minecraft version when the manifest does not repeat it. Legacy
// universals without implementation versions are accepted only with positive
// Forge content in the archive.
func verifyForgeUniversalArtifact(
file *artifact,
forgeVersion versionpkg.Version,
gameVersion versionpkg.Version,
) bool {
archive, err := file.Archive()
if err != nil {
return false
}
if installerArtifact(archive) {
return false
}
manifestForge, manifestGame := parseForgeManifest(archive)
if hasConcreteVersion(manifestForge) {
if manifestForge != forgeVersion {
return false
}
if !hasConcreteVersion(manifestGame) {
manifestGame = gameVersion
}
return manifestGame == gameVersion
}
return forgeProductContent(archive)
}
// verifyForgeServerArtifact reports whether a server jar is positively a
// Forge artifact for this layout. A vanilla server jar placed in a
// Maven-shaped Forge directory must not become a Forge runtime merely because
// its name and Main-Class are present.
func verifyForgeServerArtifact(
file *artifact,
forgeVersion versionpkg.Version,
gameVersion versionpkg.Version,
) bool {
archive, err := file.Archive()
if err != nil {
return false
}
if installerArtifact(archive) {
return false
}
manifestForge, manifestGame := parseForgeManifest(archive)
if hasConcreteVersion(manifestForge) {
if manifestForge != forgeVersion {
return false
}
if hasConcreteVersion(manifestGame) && manifestGame != gameVersion {
return false
}
return true
}
return forgeProductContent(archive)
}
// installerArtifact reports whether a jar is a loader installer rather than
// a runtime artifact.
func installerArtifact(zipReader *zip.Reader) bool {
if archiveContains(zipReader, "install_profile.json") {
return true
}
data, ok, err := readArchiveEntry(zipReader, "version.json")
if err != nil || !ok {
return false
}
_, guarded, err := mojangVersionFromJSON(data)
return err == nil && guarded
}
// archiveContains reports whether a ZIP archive has an entry with this exact
// name.
func archiveContains(zipReader *zip.Reader, name string) bool {
for _, entry := range zipReader.File {
if entry.Name == name {
return true
}
}
return false
}
// archiveManifestContains reports whether the jar manifest contains needle.
func archiveManifestContains(zipReader *zip.Reader, needle string) bool {
data, ok, err := readArchiveEntry(zipReader, "META-INF/MANIFEST.MF")
return err == nil && ok && strings.Contains(string(data), needle)
}
// verifyForgeShimArtifact requires the two bootstrap descriptors that make a
// Forge shim a launch target rather than an arbitrary jar named *-shim.jar.
func verifyForgeShimArtifact(file *artifact) bool {
archive, err := file.Archive()
if err != nil {
return false
}
hasProperties := false
hasList := false
for _, entry := range archive.File {
switch entry.Name {
case "bootstrap-shim.properties":
hasProperties = true
case "bootstrap-shim.list":
hasList = true
}
}
return hasProperties && hasList
}
package discovery
import (
"archive/zip"
"bufio"
"path/filepath"
"regexp"
"strconv"
"strings"
versionpkg "github.com/mclucy/lucy/version"
)
var forgeRuntimeVersionDirPattern = regexp.MustCompile(
`^(\d+\.\d+(?:\.\d+)?)-(\d+(?:\.\d+)+)$`,
)
var forgeJarNameVersionPattern = regexp.MustCompile(
`^forge-(\d+\.\d+(?:\.\d+)?)-(\d+(?:\.\d+)+)(?:-[a-z]+)?\.jar$`,
)
// parseForgeManifest reads the Forge and Minecraft versions from the
// per-section attributes of a Forge universal jar manifest. The manifest may
// list the implementation version before the implementation title, so the
// parser keeps one pending version and adopts it when the Forge section
// starts.
func parseForgeManifest(
zipReader *zip.Reader,
) (forgeVersion versionpkg.Version, gameVersion versionpkg.Version) {
for _, f := range zipReader.File {
if f.Name != "META-INF/MANIFEST.MF" {
continue
}
r, err := f.Open()
if err != nil {
continue
}
var inForgeSection bool
var pendingImplVersion string
var specTitle string
minecraftGameVersion := versionpkg.Unknown
var gameVersionCandidates []versionpkg.Version
s := bufio.NewScanner(r)
for s.Scan() {
line := s.Text()
switch {
case strings.HasPrefix(line, "Name: "):
inForgeSection = false
specTitle = ""
case strings.HasPrefix(line, "Specification-Title: "):
if after, found := strings.CutPrefix(
line,
"Specification-Title: ",
); found {
specTitle = strings.TrimSpace(after)
}
case line == "Implementation-Title: net.minecraftforge":
inForgeSection = true
if pendingImplVersion != "" {
forgeVersion = versionpkg.Version(pendingImplVersion)
pendingImplVersion = ""
}
case strings.HasPrefix(line, "Implementation-Version: "):
if after, found := strings.CutPrefix(
line,
"Implementation-Version: ",
); found {
switch {
case inForgeSection:
forgeVersion = versionpkg.Version(after)
case !inForgeSection && forgeVersion == "" && pendingImplVersion == "":
pendingImplVersion = after
}
}
case strings.HasPrefix(line, "Specification-Version: "):
if after, found := strings.CutPrefix(
line,
"Specification-Version: ",
); found && isMinecraftReleaseVersion(after) {
candidate := versionpkg.Version(after)
gameVersionCandidates = append(
gameVersionCandidates,
candidate,
)
if specTitle == "Minecraft" {
minecraftGameVersion = candidate
}
}
}
}
_ = r.Close()
switch {
case minecraftGameVersion != versionpkg.Unknown:
gameVersion = minecraftGameVersion
default:
// Without a Minecraft specification section, a manifest may
// still repeat the game version. Skip the loader's own
// specification, which shares the Forge version prefix.
for index := len(gameVersionCandidates) - 1; index >= 0; index-- {
candidate := gameVersionCandidates[index]
if versionSharesPrefix(candidate, forgeVersion) {
continue
}
gameVersion = candidate
break
}
}
break
}
return forgeVersion, gameVersion
}
// isMinecraftReleaseVersion reports whether value is a positively identified
// Minecraft release id. Minecraft uses dotted numeric ids: the classic 1.x
// line and the year-based 26.x line. Arbitrary plugin versions such as 2.5.0
// are not release ids.
func isMinecraftReleaseVersion(value string) bool {
parts := strings.Split(value, ".")
if len(parts) < 2 {
return false
}
for _, part := range parts {
if part == "" {
return false
}
for _, r := range part {
if r < '0' || r > '9' {
return false
}
}
}
major, err := strconv.Atoi(parts[0])
if err != nil {
return false
}
return major == 1 || major >= 26
}
func parseForgeVersionTupleFromPath(
filePath string,
) (gameVersion versionpkg.Version, forgeVersion versionpkg.Version, ok bool) {
parts := strings.Split(filepath.ToSlash(filePath), "/")
for i := 0; i < len(parts)-1; i++ {
if parts[i] != "forge" {
continue
}
match := forgeRuntimeVersionDirPattern.FindStringSubmatch(parts[i+1])
if match == nil {
continue
}
return versionpkg.Version(match[1]), versionpkg.Version(match[2]), true
}
if match := forgeJarNameVersionPattern.FindStringSubmatch(filepath.Base(filePath)); match != nil {
return versionpkg.Version(match[1]), versionpkg.Version(match[2]), true
}
return versionpkg.Unknown, versionpkg.Unknown, false
}
// versionSharesPrefix reports whether candidate is the loader version or one
// of its prefixes. Forge manifests repeat the loader specification version,
// which must not be mistaken for a Minecraft release id.
func versionSharesPrefix(candidate versionpkg.Version, loader versionpkg.Version) bool {
if candidate == "" || loader == "" {
return false
}
return candidate == loader ||
strings.HasPrefix(string(loader), string(candidate)+".")
}
func hasConcreteVersion(version versionpkg.Version) bool {
return version != "" && !version.IsInvalid() && !version.CanInfer()
}
func compareForgeMajor(version versionpkg.Version, target int) int {
major, _, _ := strings.Cut(string(version), ".")
if major == "" {
return -1
}
n, err := strconv.Atoi(major)
if err != nil {
return -1
}
switch {
case n < target:
return -1
case n > target:
return 1
default:
return 0
}
}
// parseLoaderArgs extracts the loader and Minecraft versions from the
// generated Forge/NeoForge argument sidecar. Both `--fml.forgeVersion` and
// `--fml.neoForgeVersion` are accepted because the installer uses the same
// argument shape for both products.
func parseLoaderArgs(data []byte) (
loaderVersion versionpkg.Version,
gameVersion versionpkg.Version,
) {
loaderVersion = versionpkg.Unknown
gameVersion = versionpkg.Unknown
for line := range strings.SplitSeq(string(data), "\n") {
fields := strings.Fields(strings.TrimSpace(line))
for i := 0; i+1 < len(fields); i++ {
switch fields[i] {
case "--fml.forgeVersion", "--fml.neoForgeVersion":
loaderVersion = versionpkg.Version(fields[i+1])
case "--fml.mcVersion":
gameVersion = versionpkg.Version(fields[i+1])
}
}
}
return loaderVersion, gameVersion
}
// parseLaunchTarget returns the value of --launchTarget in a generated loader
// argument sidecar. Server layouts name a server target; the client installer
// spec names a client target.
func parseLaunchTarget(data []byte) string {
for line := range strings.SplitSeq(string(data), "\n") {
fields := strings.Fields(strings.TrimSpace(line))
for i := 0; i+1 < len(fields); i++ {
if fields[i] == "--launchTarget" {
return fields[i+1]
}
}
}
return ""
}
package discovery
import (
"bufio"
"path/filepath"
"regexp"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
versionpkg "github.com/mclucy/lucy/version"
)
const geyserStandaloneBootstrapClass = "org/geysermc/geyser/platform/standalone/GeyserStandaloneBootstrap.class"
// discoverGeyser recognizes the standalone Geyser proxy jar. The embedded
// bootstrap class is a hard gate: a manifest alone is not enough.
func discoverGeyser(s *scan) ([]Installation, []Finding) {
var installations []Installation
var findings []Finding
for _, primary := range s.RootFiles() {
if !productCandidate(s, primary) {
continue
}
installation, err := inspectGeyserInstallation(s, primary)
if err != nil {
findings = append(findings, Finding{
Kind: FindingUnreadableFile,
Path: primary.Path(),
Cause: err,
})
continue
}
if installation != nil {
installations = append(installations, *installation)
}
}
return installations, findings
}
// Sources:
// - https://geysermc.org/wiki/geyser/setup/self/standalone
// - https://geysermc.org/wiki/geyser/setup/self/proxy-servers
// - https://geysermc.org/wiki/geyser/faq/
func inspectGeyserInstallation(
s *scan,
primary *artifact,
) (*Installation, error) {
manifest, ok, err := readProductEntry(s, primary, bukkitManifestPath)
if err != nil {
return nil, err
}
if !ok {
return nil, nil
}
signals := parseGeyserStandaloneManifest(manifest)
if !signals.valid() {
return nil, nil
}
hasStandaloneBootstrap, err := productEntryExists(
s,
primary,
geyserStandaloneBootstrapClass,
)
if err != nil {
return nil, err
}
if !hasStandaloneBootstrap {
return nil, nil
}
version := signals.version
if !hasConcreteVersion(version) {
version = parseGeyserStandaloneVersionFromPath(primary.Path())
}
identity := Identity{
Primary: catalog.VersionedRef{
Ecosystem: catalog.EcosystemUnspecified,
Name: input.ToProjectName("geyser"),
Version: version,
},
}
installation := NewInstallation(identity, primary.Path())
return &installation, nil
}
type geyserStandaloneManifestSignals struct {
mainClass string
version versionpkg.Version
}
func (s geyserStandaloneManifestSignals) valid() bool {
return s.mainClass == "org.geysermc.geyser.platform.standalone.GeyserStandaloneBootstrap"
}
func parseGeyserStandaloneManifest(data []byte) geyserStandaloneManifestSignals {
var signals geyserStandaloneManifestSignals
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "Main-Class: "):
signals.mainClass = strings.TrimSpace(
strings.TrimPrefix(
line,
"Main-Class: ",
),
)
case strings.HasPrefix(line, "Implementation-Version: "):
signals.version = versionpkg.Version(
strings.TrimSpace(
strings.TrimPrefix(
line,
"Implementation-Version: ",
),
),
)
}
}
return signals
}
var geyserStandaloneVersionPattern = regexp.MustCompile(
`geyser-standalone-(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)\.jar`,
)
func parseGeyserStandaloneVersionFromPath(filePath string) versionpkg.Version {
match := geyserStandaloneVersionPattern.FindStringSubmatch(
strings.ToLower(filepath.Base(filePath)),
)
if match == nil {
return versionpkg.Unknown
}
return versionpkg.Version(match[1])
}
package discovery
import (
"errors"
"fmt"
"iter"
"path/filepath"
"slices"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/server"
versionpkg "github.com/mclucy/lucy/version"
)
// FileRole describes how a member participates in an installation.
type FileRole string
const (
RoleUnknown FileRole = ""
RoleEntryPoint FileRole = "entry_point"
RolePayload FileRole = "payload"
RoleLibrary FileRole = "library"
RoleConfiguration FileRole = "configuration"
)
// Ownership describes whether a member is tracked by the local installation
// receipt and whether its content still matches the recorded hash.
type Ownership string
const (
OwnershipUnknown Ownership = ""
OwnershipUnmanaged Ownership = "unmanaged"
OwnershipManaged Ownership = "managed"
OwnershipModified Ownership = "modified"
)
// File is one member of an installation. It is data, not an open handle.
// Path is absolute and canonical.
type File struct {
Path string
Role FileRole
Ownership Ownership
}
// Identity is the observed runtime identity of an installation. Primary is a
// value; Components carry the additional runtime components that detectors
// proved. Normalize canonicalizes both in place before any comparison.
type Identity struct {
Primary catalog.VersionedRef
Components []catalog.VersionedRef
}
// Normalize canonicalizes names and ecosystems, removes exact duplicates, and
// upgrades an unknown component version to a concrete one when both occur for
// the same base identity. Conflicting concrete versions for one base identity
// are kept and reported as an error so the caller can emit a typed diagnostic;
// they are never silently erased.
func (i *Identity) Normalize() error {
if i == nil {
return errors.New("discovery: nil identity")
}
var problems []error
primary, ok := canonicalPrimary(i.Primary)
if !ok {
problems = append(problems, fmt.Errorf(
"invalid primary runtime %q",
i.Primary.StringQualified(),
))
} else {
i.Primary = primary
}
primaryKey := ""
if ok {
primaryKey = componentKey(i.Primary)
}
components := make([]catalog.VersionedRef, 0, len(i.Components))
indexes := make(map[string]int, len(i.Components))
seen := make(map[string]bool, len(i.Components))
for _, raw := range i.Components {
component, ok := canonicalComponent(raw)
if !ok {
problems = append(problems, fmt.Errorf(
"invalid runtime component %q",
raw.StringQualified(),
))
continue
}
if primaryKey != "" && componentKey(component) == primaryKey &&
!concreteVersion(i.Primary.Version) &&
concreteVersion(component.Version) {
// The component carries exact evidence for the same base
// identity; project it onto the primary value.
i.Primary.Version = component.Version
}
key := componentKey(component)
full := key + "@" + component.Version.String()
if seen[full] {
continue
}
seen[full] = true
index, exists := indexes[key]
if !exists {
indexes[key] = len(components)
components = append(components, component)
continue
}
existing := components[index]
switch {
case existing.Version == component.Version:
continue
case !concreteVersion(existing.Version) && concreteVersion(component.Version):
components[index] = component
case concreteVersion(existing.Version) && !concreteVersion(component.Version):
continue
default:
// Both versions are concrete and different. Keep both so the
// contradiction remains observable.
components = append(components, component)
}
}
slices.SortFunc(components, compareComponent)
i.Components = components
if ok && i.Primary.Ecosystem == catalog.EcosystemMinecraft {
i.Components = ensureMinecraftComponent(i.Components, i.Primary)
}
if err := i.contradiction(); err != nil {
problems = append(problems, err)
}
return errors.Join(problems...)
}
// IsValid reports whether the identity has a canonical primary and only
// canonical, non-contradictory components. Unknown versions remain valid.
func (i Identity) IsValid() bool {
primary, ok := canonicalPrimary(i.Primary)
if !ok || primary != i.Primary {
return false
}
if i.contradiction() != nil {
return false
}
for _, component := range i.Components {
canonical, ok := canonicalComponent(component)
if !ok || canonical != component {
return false
}
}
return true
}
// IsConcrete reports whether every version the identity carries is an exact
// observed version. Only concrete identities may prove equality for merging.
func (i Identity) IsConcrete() bool {
if !i.IsValid() || !concreteVersion(i.Primary.Version) {
return false
}
for _, component := range i.Components {
if redundantPrimaryComponent(i.Primary, component) {
continue
}
if !concreteVersion(component.Version) {
return false
}
}
return true
}
// Equal compares the canonical complete structure of two identities. A
// component that merely repeats the primary identity adds no structure and is
// ignored.
func (i Identity) Equal(other Identity) bool {
return identityKey(i) == identityKey(other)
}
// Product returns the executable server product named by the primary runtime.
func (i Identity) Product() server.Product {
if i.Primary.Name == "" {
return server.ProductUnknown
}
match, ok := server.NormalizeProduct(i.Primary)
if !ok {
return server.ProductUnknown
}
return match.Product
}
// MinecraftVersion returns the concrete Minecraft version carried by the
// identity, or version.Unknown when no single concrete version is known.
func (i Identity) MinecraftVersion() versionpkg.Version {
if i.contradictsKey(componentKey(catalog.VersionedRef{
Ecosystem: catalog.EcosystemMinecraft,
Name: "minecraft",
})) {
return versionpkg.Unknown
}
for _, component := range i.Components {
if component.Ecosystem != catalog.EcosystemMinecraft ||
component.Name != "minecraft" {
continue
}
if concreteVersion(component.Version) {
return component.Version
}
}
if i.Primary.Ecosystem == catalog.EcosystemMinecraft &&
concreteVersion(i.Primary.Version) {
return i.Primary.Version
}
return versionpkg.Unknown
}
// Ecosystem reports the mod-loader ecosystem of the identity. It falls back to
// Minecraft when no loader component applies.
func (i Identity) Ecosystem() catalog.Ecosystem {
if !i.IsValid() {
return catalog.EcosystemUnspecified
}
name := strings.ToLower(i.Primary.Name.String())
switch {
case name == "fabric" || name == "fabric-loader":
if i.Primary.Ecosystem == catalog.EcosystemFabric ||
i.Primary.Ecosystem == catalog.EcosystemUnspecified {
return catalog.EcosystemFabric
}
case name == "forge":
if i.Primary.Ecosystem == catalog.EcosystemForge ||
i.Primary.Ecosystem == catalog.EcosystemUnspecified {
return catalog.EcosystemForge
}
case name == "neoforge":
if i.Primary.Ecosystem == catalog.EcosystemNeoForge ||
i.Primary.Ecosystem == catalog.EcosystemUnspecified {
return catalog.EcosystemNeoForge
}
}
for _, component := range i.Components {
name := strings.ToLower(component.Name.String())
switch {
case component.Ecosystem == catalog.EcosystemFabric &&
(name == "fabric-loader" || name == "fabricloader"):
return catalog.EcosystemFabric
case component.Ecosystem == catalog.EcosystemForge && name == "forge":
return catalog.EcosystemForge
case component.Ecosystem == catalog.EcosystemNeoForge && name == "neoforge":
return catalog.EcosystemNeoForge
}
}
return catalog.EcosystemMinecraft
}
func (i Identity) canonical() Identity {
copied := Identity{
Primary: i.Primary,
Components: slices.Clone(i.Components),
}
_ = copied.Normalize()
return copied
}
func (i Identity) contradiction() error {
versions := make(map[string]map[versionpkg.Version]struct{})
record := func(ref catalog.VersionedRef) {
if !concreteVersion(ref.Version) {
return
}
key := componentKey(ref)
if versions[key] == nil {
versions[key] = make(map[versionpkg.Version]struct{})
}
versions[key][ref.Version] = struct{}{}
}
if primary, ok := canonicalPrimary(i.Primary); ok {
record(primary)
}
for _, component := range i.Components {
record(component)
}
var problems []error
keys := make([]string, 0, len(versions))
for key := range versions {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
found := versions[key]
if len(found) <= 1 {
continue
}
concrete := make([]versionpkg.Version, 0, len(found))
for value := range found {
concrete = append(concrete, value)
}
slices.Sort(concrete)
problems = append(problems, fmt.Errorf(
"conflicting concrete versions for %s: %s",
key,
strings.Join(versionStrings(concrete), " and "),
))
}
return errors.Join(problems...)
}
func (i Identity) contradictsKey(key string) bool {
versions := make(map[versionpkg.Version]struct{})
if primary, ok := canonicalPrimary(i.Primary); ok &&
componentKey(primary) == key &&
concreteVersion(primary.Version) {
versions[primary.Version] = struct{}{}
}
for _, component := range i.Components {
if componentKey(component) == key && concreteVersion(component.Version) {
versions[component.Version] = struct{}{}
}
}
return len(versions) > 1
}
func canonicalPrimary(ref catalog.VersionedRef) (catalog.VersionedRef, bool) {
name := strings.ToLower(strings.TrimSpace(ref.Name.String()))
if ref.Version == "" {
ref.Version = versionpkg.Unknown
}
if name == "" || !ref.Ecosystem.Valid() ||
(!concreteVersion(ref.Version) && ref.Version != versionpkg.Unknown) {
return catalog.VersionedRef{}, false
}
ref.Provider = catalog.ProviderUnspecified
ref.Name = catalog.Name(name)
return ref, true
}
func canonicalComponent(ref catalog.VersionedRef) (catalog.VersionedRef, bool) {
name := strings.ToLower(strings.TrimSpace(ref.Name.String()))
if ref.Version == "" {
ref.Version = versionpkg.Unknown
}
if name == "" || (!concreteVersion(ref.Version) && ref.Version != versionpkg.Unknown) {
return catalog.VersionedRef{}, false
}
switch ref.Ecosystem {
case catalog.EcosystemMinecraft:
if name != "minecraft" {
return catalog.VersionedRef{}, false
}
name = "minecraft"
case catalog.EcosystemFabric:
switch name {
case "fabric", "fabric-loader", "fabricloader":
name = "fabric-loader"
case "fabric-api":
name = "fabric-api"
default:
return catalog.VersionedRef{}, false
}
case catalog.EcosystemForge:
if name != "forge" {
return catalog.VersionedRef{}, false
}
case catalog.EcosystemNeoForge:
if name != "neoforge" {
return catalog.VersionedRef{}, false
}
default:
return catalog.VersionedRef{}, false
}
ref.Provider = catalog.ProviderUnspecified
ref.Name = catalog.Name(name)
return ref, true
}
func componentKey(ref catalog.VersionedRef) string {
return ref.Ecosystem.String() + "/" + ref.Name.String()
}
func ensureMinecraftComponent(
components []catalog.VersionedRef,
primary catalog.VersionedRef,
) []catalog.VersionedRef {
for index := range components {
if components[index].Ecosystem != catalog.EcosystemMinecraft ||
components[index].Name != "minecraft" {
continue
}
if !concreteVersion(components[index].Version) &&
concreteVersion(primary.Version) {
components[index].Version = primary.Version
slices.SortFunc(components, compareComponent)
}
return components
}
return append(components, catalog.VersionedRef{
Ecosystem: catalog.EcosystemMinecraft,
Name: "minecraft",
Version: primary.Version,
})
}
func compareComponent(left, right catalog.VersionedRef) int {
if result := strings.Compare(left.Ecosystem.String(), right.Ecosystem.String()); result != 0 {
return result
}
if result := strings.Compare(left.Name.String(), right.Name.String()); result != 0 {
return result
}
return strings.Compare(left.Version.String(), right.Version.String())
}
func versionStrings(versions []versionpkg.Version) []string {
out := make([]string, 0, len(versions))
for _, value := range versions {
out = append(out, value.String())
}
return out
}
func concreteVersion(value versionpkg.Version) bool {
switch value {
case "", versionpkg.None, versionpkg.Unknown, versionpkg.Any,
versionpkg.Stable, versionpkg.Beta:
return false
default:
return true
}
}
// Installation is one observed runtime together with every member the
// recognizer proved belongs to it. All entry points, payloads, libraries, and
// configuration sidecars live in the single Files collection.
type Installation struct {
Identity Identity
Files []File
}
// NewInstallation creates an unmanaged entry-point installation. Central
// reconciliation normalizes the identity.
func NewInstallation(identity Identity, entryPoint string) Installation {
installation := Installation{Identity: identity}
if entryPoint != "" {
installation.AddFile(entryPoint, RoleEntryPoint)
}
return installation
}
// AddFile unions a member by path. A dependency member upgrades to an entry
// point when the same identity explicitly launches it; an existing entry point
// never downgrades.
func (i *Installation) AddFile(path string, role FileRole) {
i.addFile(File{
Path: path,
Role: role,
Ownership: OwnershipUnmanaged,
})
}
// EntryPoints iterates the entry-point members of the single Files collection.
func (i *Installation) EntryPoints() iter.Seq[*File] {
return func(yield func(*File) bool) {
if i == nil {
return
}
for index := range i.Files {
if i.Files[index].Role != RoleEntryPoint {
continue
}
if !yield(&i.Files[index]) {
return
}
}
}
}
func (i *Installation) addFile(file File) {
if i == nil || file.Path == "" {
return
}
file.Path = filepath.Clean(file.Path)
if file.Ownership == OwnershipUnknown {
file.Ownership = OwnershipUnmanaged
}
for index := range i.Files {
existing := &i.Files[index]
if existing.Path != file.Path {
continue
}
if file.Role == RoleEntryPoint {
existing.Role = RoleEntryPoint
}
existing.Ownership = strongerOwnership(existing.Ownership, file.Ownership)
return
}
i.Files = append(i.Files, file)
}
func (i *Installation) removeFile(path string) {
if i == nil {
return
}
path = filepath.Clean(path)
files := i.Files[:0]
for _, file := range i.Files {
if file.Path == path {
continue
}
files = append(files, file)
}
clear(i.Files[len(files):])
i.Files = files
}
func strongerOwnership(left, right Ownership) Ownership {
rank := func(value Ownership) int {
switch value {
case OwnershipManaged:
return 3
case OwnershipModified:
return 2
case OwnershipUnmanaged:
return 1
default:
return 0
}
}
if rank(left) >= rank(right) {
return left
}
return right
}
// identityKey is the stable canonical key used for ordering and grouping.
func identityKey(identity Identity) string {
canonical := identity.canonical()
primaryKey := componentKey(canonical.Primary)
parts := make([]string, 0, 1+len(canonical.Components))
parts = append(parts, primaryKey+"@"+canonical.Primary.Version.String())
for _, component := range canonical.Components {
if redundantPrimaryComponent(canonical.Primary, component) {
continue
}
parts = append(parts, componentKey(component)+"@"+component.Version.String())
}
return strings.Join(parts, "|")
}
func redundantPrimaryComponent(
primary catalog.VersionedRef,
component catalog.VersionedRef,
) bool {
if componentKey(primary) != componentKey(component) {
return false
}
if component.Version == primary.Version {
return true
}
return !concreteVersion(component.Version) && concreteVersion(primary.Version)
}
func compareIdentities(left, right Identity) int {
return strings.Compare(identityKey(left), identityKey(right))
}
package discovery
import (
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/mclucy/lucy/catalog"
versionpkg "github.com/mclucy/lucy/version"
)
var neoforgeVersionDirPattern = regexp.MustCompile(`^(\d+)\.(\d+)(?:\.\d+)*(?:-[a-zA-Z0-9.]+)*$`)
// parseModLoaderVersionTuple derives the Minecraft and loader versions from a
// Maven version directory name. NeoForge used to drop the Minecraft "1."
// prefix (20.2.x -> 1.20.2, 21.1.x -> 1.21.1); the year scheme keeps the
// Minecraft triple and appends a build (26.2.0.67 -> 26.2). Only the
// directory name is considered; content evidence refines the result where
// the loader publishes it.
func parseModLoaderVersionTuple(
versionDir string,
platform catalog.Ecosystem,
) (gameVersion, loaderVersion versionpkg.Version, ok bool) {
name := filepath.Base(versionDir)
switch platform {
case catalog.EcosystemForge:
match := forgeRuntimeVersionDirPattern.FindStringSubmatch(name)
if match == nil {
return versionpkg.Unknown, versionpkg.Unknown, false
}
return versionpkg.Version(match[1]), versionpkg.Version(match[2]), true
case catalog.EcosystemNeoForge:
if neoforgeVersionDirPattern.FindStringSubmatch(name) == nil {
return versionpkg.Unknown, versionpkg.Unknown, false
}
gameVersion = neoforgeGameVersionFromTuple(name)
if gameVersion == versionpkg.Unknown {
return versionpkg.Unknown, versionpkg.Unknown, false
}
return gameVersion, versionpkg.Version(name), true
default:
return versionpkg.Unknown, versionpkg.Unknown, false
}
}
// neoforgeGameVersionFromTuple maps a NeoForge loader version to the
// Minecraft release it targets. Classic lines drop the leading 1; year-scheme
// lines keep the Minecraft triple and add a build segment, where a zero patch
// is not part of the release id (26.2.0.67 -> 26.2, 26.2.1.5 -> 26.2.1).
func neoforgeGameVersionFromTuple(name string) versionpkg.Version {
segments := strings.Split(strings.SplitN(name, "-", 2)[0], ".")
if len(segments) < 2 {
return versionpkg.Unknown
}
major, err := strconv.Atoi(segments[0])
if err != nil {
return versionpkg.Unknown
}
if major < 26 {
return versionpkg.Version("1." + segments[0] + "." + segments[1])
}
gameVersion := segments[0] + "." + segments[1]
if len(segments) >= 4 && segments[2] != "0" {
gameVersion += "." + segments[2]
}
return versionpkg.Version(gameVersion)
}
package discovery
import (
"archive/zip"
"encoding/json/v2"
"path/filepath"
"strings"
"github.com/mclucy/lucy/catalog"
versionpkg "github.com/mclucy/lucy/version"
)
const (
neoForgeLibraryRoot = "libraries/net/neoforged/neoforge"
neoForgePatchedServerRoot = "libraries/net/neoforged/minecraft-server-patched"
neoForgeVersionPropertiesPath = "net/neoforged/neoforge/common/version.properties"
neoForgeModsTomlPath = "META-INF/neoforge.mods.toml"
)
// discoverNeoForge recognizes installed NeoForge servers under the NeoForge
// Maven library directory. The installer only provisions the layout; the
// runtime identity and its members are proven from local archives and the
// generated argument sidecars. The universal jar is the entry point when no
// server jar is present; otherwise it is a runtime library.
func discoverNeoForge(s *scan) ([]Installation, []Finding) {
if s == nil {
return nil, nil
}
root := filepath.Join(s.root, filepath.FromSlash(neoForgeLibraryRoot))
entries, err := s.ReadDir(root)
if err != nil {
return nil, nil
}
installations := make([]Installation, 0)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
installation, ok := neoForgeInstallation(
s,
filepath.Join(root, entry.Name()),
)
if ok {
installations = append(installations, installation)
}
}
return installations, nil
}
// neoForgeInstallation builds one installation from a
// libraries/net/neoforged/neoforge/<version>/ directory.
func neoForgeInstallation(s *scan, versionDir string) (Installation, bool) {
loaderVersion := versionpkg.Version(filepath.Base(versionDir))
tupleGameVersion, _, ok := parseModLoaderVersionTuple(
versionDir,
catalog.EcosystemNeoForge,
)
if !ok {
return Installation{}, false
}
universal, hasUniversal := neoForgeArtifact(
s,
versionDir,
"neoforge-"+string(loaderVersion)+"-universal.jar",
)
server, hasServer := neoForgeArtifact(
s,
versionDir,
"neoforge-"+string(loaderVersion)+"-server.jar",
)
unixArgs, hasUnixArgs := neoForgeArtifact(s, versionDir, "unix_args.txt")
winArgs, hasWinArgs := neoForgeArtifact(s, versionDir, "win_args.txt")
universalGameVersion, universalValid := versionpkg.Unknown, false
if hasUniversal {
universalGameVersion, universalValid = neoForgeUniversalEvidence(
universal,
loaderVersion,
)
}
serverGameVersion, serverReadable := versionpkg.Unknown, false
if hasServer {
serverGameVersion, serverReadable = neoForgeServerEvidence(server)
}
patched, hasPatched := neoForgePatchedServerArtifact(s, loaderVersion)
patchedGameVersion, patchedReadable := versionpkg.Unknown, false
if hasPatched {
patchedGameVersion, patchedReadable = neoForgeServerEvidence(patched)
}
gameVersion := tupleGameVersion
switch {
case universalValid && hasConcreteVersion(universalGameVersion):
gameVersion = universalGameVersion
default:
if argsGameVersion := neoForgeArgsGameVersion(
s,
versionDir,
loaderVersion,
hasUnixArgs,
hasWinArgs,
); hasConcreteVersion(argsGameVersion) {
gameVersion = argsGameVersion
} else if installerGameVersion := neoForgeInstallerGameVersion(
s,
loaderVersion,
); hasConcreteVersion(installerGameVersion) {
gameVersion = installerGameVersion
} else if hasConcreteVersion(serverGameVersion) {
gameVersion = serverGameVersion
} else if hasConcreteVersion(patchedGameVersion) {
gameVersion = patchedGameVersion
}
}
unixArgsValid := hasUnixArgs &&
loaderArgsLaunchValid(unixArgs, loaderVersion, gameVersion)
winArgsValid := hasWinArgs &&
loaderArgsLaunchValid(winArgs, loaderVersion, gameVersion)
serverRunnable := serverReadable && artifactHasLaunchMainClass(server) &&
verifyNeoForgeServerArtifact(server, loaderVersion)
universalRunnable := universalValid && artifactHasLaunchMainClass(universal)
entries := make([]*artifact, 0, 2)
switch {
case (unixArgsValid || winArgsValid) &&
(universalValid || serverReadable || patchedReadable):
// The generated argument sidecar is the Java launch target for an
// installed NeoForge server.
if unixArgsValid {
entries = append(entries, unixArgs)
}
if winArgsValid {
entries = append(entries, winArgs)
}
case serverRunnable:
entries = append(entries, server)
case universalRunnable:
// A universal jar is bootable only when it declares its own launch
// entry class; otherwise it is a runtime library.
entries = append(entries, universal)
default:
return Installation{}, false
}
installation := NewInstallation(
neoForgeIdentity(gameVersion, loaderVersion),
entries[0].Path(),
)
for _, entry := range entries[1:] {
installation.AddFile(entry.Path(), RoleEntryPoint)
}
if universalValid && !containsArtifact(entries, universal) {
installation.AddFile(universal.Path(), RoleLibrary)
}
if serverReadable && !containsArtifact(entries, server) {
installation.AddFile(server.Path(), RolePayload)
}
if patchedReadable && !containsArtifact(entries, patched) {
installation.AddFile(patched.Path(), RolePayload)
}
if hasUnixArgs && !unixArgsValid {
installation.AddFile(unixArgs.Path(), RoleConfiguration)
}
if hasWinArgs && !winArgsValid {
installation.AddFile(winArgs.Path(), RoleConfiguration)
}
addMatchingMinecraftPayload(s, &installation, gameVersion)
addMinecraftServerLibraryPayload(s, &installation, gameVersion)
return installation, true
}
func neoForgeArtifact(
s *scan,
versionDir string,
name string,
) (*artifact, bool) {
file, ok := s.File(filepath.Join(versionDir, name))
if !ok || file.IsDirectory() {
return nil, false
}
return file, true
}
// neoForgePatchedServerArtifact resolves the patched Minecraft server jar
// that current NeoForge installers place under their own Maven coordinate.
func neoForgePatchedServerArtifact(
s *scan,
loaderVersion versionpkg.Version,
) (*artifact, bool) {
version := string(loaderVersion)
path := filepath.Join(
s.root,
filepath.FromSlash(neoForgePatchedServerRoot),
version,
"minecraft-server-patched-"+version+".jar",
)
file, ok := s.File(path)
if !ok || file.IsDirectory() {
return nil, false
}
return file, true
}
// verifyNeoForgeServerArtifact reports whether a server jar is positively a
// NeoForge artifact for this layout. A vanilla server jar placed in a
// Maven-shaped NeoForge directory must not become a NeoForge runtime merely
// because its name and Main-Class are present.
func verifyNeoForgeServerArtifact(
file *artifact,
loaderVersion versionpkg.Version,
) bool {
archive, err := file.Archive()
if err != nil {
return false
}
if installerArtifact(archive) {
return false
}
if data, ok, err := readArchiveEntry(
archive,
neoForgeVersionPropertiesPath,
); err == nil && ok {
recorded := versionpkg.Version(
propertiesValue(data, "neoforge_version"),
)
return recorded == loaderVersion
}
if data, ok, err := readArchiveEntry(
archive,
neoForgeModsTomlPath,
); err == nil && ok {
if modVersion, found := parseTomlModVersion(data, "neoforge"); found {
return versionpkg.Version(modVersion) == loaderVersion
}
}
return archiveManifestContains(archive, "FML-System-Mods: neoforge")
}
// neoForgeUniversalEvidence validates a NeoForge universal jar and returns
// the Minecraft version the archive records locally, when it records one.
func neoForgeUniversalEvidence(
file *artifact,
loaderVersion versionpkg.Version,
) (versionpkg.Version, bool) {
archive, err := file.Archive()
if err != nil {
return versionpkg.Unknown, false
}
if installerArtifact(archive) {
return versionpkg.Unknown, false
}
if data, ok, err := readArchiveEntry(
archive,
neoForgeVersionPropertiesPath,
); err == nil && ok {
recordedLoader := versionpkg.Version(
propertiesValue(data, "neoforge_version"),
)
if recordedLoader != loaderVersion {
return versionpkg.Unknown, false
}
gameVersion := versionpkg.Version(
propertiesValue(data, "minecraft_version"),
)
if hasConcreteVersion(gameVersion) &&
isMinecraftReleaseVersion(string(gameVersion)) {
return gameVersion, true
}
return versionpkg.Unknown, true
}
if data, ok, err := readArchiveEntry(
archive,
neoForgeModsTomlPath,
); err == nil && ok {
if modVersion, found := parseTomlModVersion(data, "neoforge"); found {
if versionpkg.Version(modVersion) != loaderVersion {
return versionpkg.Unknown, false
}
return versionpkg.Unknown, true
}
}
ok, err := verifyNeoForgeUniversalManifest(archive, loaderVersion)
if err != nil || !ok {
return versionpkg.Unknown, false
}
return versionpkg.Unknown, true
}
// neoForgeServerEvidence validates a NeoForge server jar and reads the
// Minecraft version from its embedded version spec when present.
func neoForgeServerEvidence(
file *artifact,
) (versionpkg.Version, bool) {
archive, err := file.Archive()
if err != nil {
return versionpkg.Unknown, false
}
if installerArtifact(archive) {
return versionpkg.Unknown, false
}
data, ok, err := readArchiveEntry(archive, mojangVersionJSONEntry)
if err != nil || !ok {
return versionpkg.Unknown, true
}
gameVersion, guarded, err := mojangVersionFromJSON(data)
if err != nil || guarded || !hasConcreteVersion(gameVersion) ||
!isMinecraftReleaseVersion(string(gameVersion)) {
return versionpkg.Unknown, true
}
return gameVersion, true
}
// neoForgeArgsGameVersion reads the Minecraft version from the generated
// argument sidecar, which is authoritative for the installed layout.
func neoForgeArgsGameVersion(
s *scan,
versionDir string,
loaderVersion versionpkg.Version,
hasUnixArgs bool,
hasWinArgs bool,
) versionpkg.Version {
names := make([]string, 0, 2)
if hasUnixArgs {
names = append(names, "unix_args.txt")
}
if hasWinArgs {
names = append(names, "win_args.txt")
}
for _, name := range names {
file, ok := neoForgeArtifact(s, versionDir, name)
if !ok {
continue
}
data, err := file.Read()
if err != nil {
continue
}
argsLoader, argsGame := parseLoaderArgs(data)
if hasConcreteVersion(argsLoader) && argsLoader != loaderVersion {
continue
}
if hasConcreteVersion(argsGame) &&
isMinecraftReleaseVersion(string(argsGame)) {
return argsGame
}
}
return versionpkg.Unknown
}
// neoForgeInstallerGameVersion reads the Minecraft version from the installer
// metadata kept beside the installed layout. The installer is provisioning
// metadata, not a runtime; its version spec still names the exact game
// release the loader targets.
func neoForgeInstallerGameVersion(
s *scan,
loaderVersion versionpkg.Version,
) versionpkg.Version {
installerPath := filepath.Join(
s.root,
"neoforge-"+string(loaderVersion)+"-installer.jar",
)
installer, ok := s.File(installerPath)
if !ok || installer.IsDirectory() {
return versionpkg.Unknown
}
archive, err := installer.Archive()
if err != nil {
return versionpkg.Unknown
}
data, ok, err := readArchiveEntry(archive, "version.json")
if err != nil || !ok {
return versionpkg.Unknown
}
var spec struct {
InheritsFrom string `json:"inheritsFrom"`
Arguments struct {
Game []string `json:"game"`
} `json:"arguments"`
}
if err := json.Unmarshal(data, &spec); err != nil {
return versionpkg.Unknown
}
argsLoader, argsGame := parseLoaderArgs(
[]byte(strings.Join(spec.Arguments.Game, " ")),
)
if hasConcreteVersion(argsLoader) && argsLoader != loaderVersion {
return versionpkg.Unknown
}
if hasConcreteVersion(argsGame) &&
isMinecraftReleaseVersion(string(argsGame)) {
return argsGame
}
inherited := versionpkg.Version(strings.TrimSpace(spec.InheritsFrom))
if hasConcreteVersion(inherited) &&
isMinecraftReleaseVersion(string(inherited)) {
return inherited
}
return versionpkg.Unknown
}
// parseTomlModVersion returns the version declared by one [[mods]] block in a
// NeoForge mods TOML file.
func parseTomlModVersion(data []byte, modID string) (string, bool) {
currentID := ""
currentVersion := ""
flush := func() (string, bool) {
matched := currentID == modID && currentVersion != ""
version := currentVersion
currentID = ""
currentVersion = ""
return version, matched
}
for line := range strings.SplitSeq(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[[") {
if version, matched := flush(); matched {
return version, true
}
continue
}
key, value, found := strings.Cut(trimmed, "=")
if !found {
continue
}
switch strings.TrimSpace(key) {
case "modId":
currentID = strings.Trim(strings.TrimSpace(value), `"'`)
case "version":
currentVersion = strings.Trim(strings.TrimSpace(value), `"'`)
}
}
return flush()
}
// verifyNeoForgeUniversalManifest checks the two manifest markers NeoForge
// universal jars carry: the system-mod declaration and the loader classpath
// entry.
func verifyNeoForgeUniversalManifest(
reader *zip.Reader,
loaderVersion versionpkg.Version,
) (bool, error) {
manifest, ok, err := readArchiveEntry(reader, "META-INF/MANIFEST.MF")
if err != nil || !ok {
return false, err
}
if strings.Contains(string(manifest), "FML-System-Mods: neoforge") {
return true, nil
}
classpathEntry := "libraries/net/neoforged/neoforge/" +
string(loaderVersion) + "/"
return strings.Contains(string(manifest), classpathEntry), nil
}
func neoForgeIdentity(
gameVersion versionpkg.Version,
loaderVersion versionpkg.Version,
) Identity {
return Identity{
Primary: catalog.VersionedRef{
Ecosystem: catalog.EcosystemNeoForge,
Name: "neoforge",
Version: loaderVersion,
},
Components: []catalog.VersionedRef{
{
Ecosystem: catalog.EcosystemNeoForge,
Name: "neoforge",
Version: loaderVersion,
},
{
Ecosystem: catalog.EcosystemMinecraft,
Name: "minecraft",
Version: gameVersion,
},
},
}
}
package discovery
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
const ownershipReceiptPath = ".lucy/install.yaml"
type ownershipReceipt struct {
Version int `yaml:"version"`
Entries []ownershipReceiptEntry `yaml:"entries"`
}
type ownershipReceiptEntry struct {
Path string `yaml:"path"`
SHA256 string `yaml:"sha256"`
}
// ObserveOwnership verifies the local installation receipt against every
// observed member. Receipt paths are workspace-anchor-relative even when the
// server directory is nested. A recorded path alone never proves management:
// the content hash must match. A member whose recorded content cannot be read
// keeps OwnershipUnknown and never counts as modified. The method never writes
// files.
func (r *Result) ObserveOwnership(workspaceRoot string) {
if r == nil {
return
}
r.clearOwnershipDiagnostics()
for index := range r.Installations {
for fileIndex := range r.Installations[index].Files {
r.Installations[index].Files[fileIndex].Ownership = OwnershipUnmanaged
}
}
absoluteRoot, err := filepath.Abs(workspaceRoot)
if err != nil {
r.appendReceiptError(workspaceRoot, err)
return
}
root, err := os.OpenRoot(absoluteRoot)
if err != nil {
r.appendReceiptError(absoluteRoot, err)
return
}
defer root.Close()
data, err := root.ReadFile(ownershipReceiptPath)
if errors.Is(err, os.ErrNotExist) {
return
}
if err != nil {
r.appendReceiptError(filepath.Join(absoluteRoot, ownershipReceiptPath), err)
return
}
var receipt ownershipReceipt
if err := yaml.Unmarshal(data, &receipt); err != nil {
r.appendReceiptError(
filepath.Join(absoluteRoot, ownershipReceiptPath),
fmt.Errorf("parse installation receipt: %w", err),
)
return
}
if receipt.Version != 1 {
r.appendReceiptError(
filepath.Join(absoluteRoot, ownershipReceiptPath),
fmt.Errorf("unsupported installation receipt version %d", receipt.Version),
)
return
}
// Duplicate receipt entries are kept: a path may carry several recorded
// hashes, and any match proves the observed content.
recorded := make(map[string][]string, len(receipt.Entries))
for _, entry := range receipt.Entries {
relative := filepath.Clean(filepath.FromSlash(entry.Path))
if !filepath.IsLocal(relative) || relative == "." {
continue
}
hash := normalizeHash(entry.SHA256)
recorded[relative] = append(recorded[relative], hash)
if resolved, resolveErr := filepath.EvalSymlinks(
filepath.Join(absoluteRoot, relative),
); resolveErr == nil {
if target, ok := relativeWithinRoot(absoluteRoot, resolved); ok &&
target != relative {
recorded[target] = append(recorded[target], hash)
}
}
}
verified := make(map[string]verifiedHash, len(recorded))
for index := range r.Installations {
installation := &r.Installations[index]
for fileIndex := range installation.Files {
file := &installation.Files[fileIndex]
relative, ok := relativeWithinRoot(absoluteRoot, file.Path)
if !ok {
continue
}
hashes, listed := recorded[relative]
if !listed {
continue
}
observed, ok := verified[relative]
if !ok {
hash, hashErr := hashRootFile(root, relative)
observed = verifiedHash{hash: hash, err: hashErr}
verified[relative] = observed
}
if observed.err != nil {
file.Ownership = OwnershipUnknown
r.Diagnostics = append(r.Diagnostics, Finding{
Kind: FindingOwnershipError,
Path: file.Path,
Identity: installation.Identity,
Cause: observed.err,
})
continue
}
if matchesRecordedHash(observed.hash, hashes) {
file.Ownership = OwnershipManaged
continue
}
file.Ownership = OwnershipModified
r.Diagnostics = append(r.Diagnostics, Finding{
Kind: FindingModifiedFile,
Path: file.Path,
Identity: installation.Identity,
})
}
}
}
type verifiedHash struct {
hash string
err error
}
// relativeWithinRoot resolves a member path against the workspace anchor even
// when one side uses a symlinked spelling. It returns a path relative to the
// anchor spelling first, then to the resolved anchor.
func relativeWithinRoot(root, path string) (string, bool) {
roots := []string{root}
if resolved, err := filepath.EvalSymlinks(root); err == nil && resolved != root {
roots = append(roots, resolved)
}
paths := []string{path}
if resolved, err := filepath.EvalSymlinks(path); err == nil && resolved != path {
paths = append(paths, resolved)
}
for _, candidateRoot := range roots {
for _, candidatePath := range paths {
rel, err := filepath.Rel(candidateRoot, candidatePath)
if err == nil && rel != "." && filepath.IsLocal(rel) {
return rel, true
}
}
}
return "", false
}
func hashRootFile(root *os.Root, relative string) (string, error) {
info, err := root.Stat(relative)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", errNotRegularFile
}
file, err := root.Open(relative)
if err != nil {
return "", err
}
hash := sha256.New()
_, copyErr := io.Copy(hash, file)
closeErr := file.Close()
if copyErr != nil {
return "", copyErr
}
if closeErr != nil {
return "", closeErr
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func matchesRecordedHash(observed string, recorded []string) bool {
for _, expected := range recorded {
if expected != "" && observed == expected {
return true
}
}
return false
}
func normalizeHash(hash string) string {
return strings.ToLower(strings.TrimSpace(hash))
}
func (r *Result) appendReceiptError(path string, err error) {
if r == nil {
return
}
r.Diagnostics = append(r.Diagnostics, Finding{
Kind: FindingReceiptError,
Path: path,
Cause: err,
})
}
func (r *Result) clearOwnershipDiagnostics() {
if r == nil || len(r.Diagnostics) == 0 {
return
}
kept := r.Diagnostics[:0]
for _, finding := range r.Diagnostics {
switch finding.Kind {
case FindingReceiptError, FindingOwnershipError, FindingModifiedFile:
continue
}
kept = append(kept, finding)
}
clear(r.Diagnostics[len(kept):])
r.Diagnostics = kept
}
package discovery
import (
"archive/zip"
"encoding/json/v2"
"fmt"
"io/fs"
"path/filepath"
"strings"
"github.com/mclucy/lucy/internal/fsutil"
versionpkg "github.com/mclucy/lucy/version"
)
const (
paperMetaMainClassPath = "META-INF/main-class"
paperLibrariesListPath = "META-INF/libraries.list"
paperVersionsListPath = "META-INF/versions.list"
paperPatchesListPath = "META-INF/patches.list"
paperDownloadContextPath = "META-INF/download-context"
paperVersionJSONPath = "version.json"
paperPatchPropertiesPath = "patch.properties"
paperPatchReaperToken = "paperMC.patch"
paperPatchFilePath = paperPatchReaperToken
paperBuildInfoPath = "META-INF/build-info"
paperLeavesclipVersionPath = "META-INF/leavesclip-version"
paperLeaperNamespacePrefix = "cn/dreeam/leaper/"
paperLeavesclipNamespace = "org/leavesmc/leavesclip/"
paperPaperclipNamespace = "io/papermc/paperclip/"
paperLegacyPaperclipPrefix = "paperclip/"
paperYouerNamespace = "com/mohistmc/launcher/youer/"
paperManifestYouerToken = "Youer"
paperMainClassYouerToken = "youer"
paperLibraryPaperToken = "io.papermc.paper:paper-api:"
paperLibraryFoliaToken = "dev.folia:folia-api:"
paperLibraryDivineToken = "org.bxteam.divinemc:divinemc-api:"
paperLibraryPurpurToken = "org.purpurmc.purpur:purpur-api:"
paperLibraryLeafToken = "cn.dreeam.leaf:leaf-api:"
paperLibraryLeavesToken = "org.leavesmc.leaves:leaves-api:"
)
type paperObservationEntry struct {
name string
read func() ([]byte, error)
}
type paperObservationSource interface {
Walk(func(paperObservationEntry) error) error
}
func extractPaperObservations(
s *scan,
primary *artifact,
) (paperObservations, error) {
obs := paperObservations{
patchProperties: make(map[string]string),
}
source, err := newPaperObservationSource(s, primary)
if err != nil {
return paperObservations{}, err
}
err = source.Walk(
func(entry paperObservationEntry) error {
name := entry.name
switch {
case strings.HasPrefix(
name,
bukkitPaperClassPrefix,
), strings.HasPrefix(name, bukkitLegacyPaperClassPrefix):
obs.hasPaperClasses = true
case strings.HasPrefix(name, paperLeaperNamespacePrefix):
obs.hasLeaperNamespace = true
case strings.HasPrefix(name, paperLeavesclipNamespace):
obs.hasLeavesclipNamespace = true
case strings.HasPrefix(name, paperPaperclipNamespace):
obs.hasPaperclipNamespace = true
case strings.HasPrefix(name, paperLegacyPaperclipPrefix):
obs.hasLegacyPaperclipNamespace = true
case strings.HasPrefix(name, paperYouerNamespace):
obs.hasYouerNamespace = true
}
switch name {
case bukkitManifestPath:
data, err := entry.read()
if err != nil {
return err
}
signals := parseBukkitManifest(data)
obs.manifestMainClass = signals.mainClass
obs.manifestSpecificationTitle = signals.specificationTitle
obs.manifestSpecificationVendor = signals.specificationVendor
obs.manifestImplementationTitle = signals.implementationTitle
obs.manifestImplementationVendor = signals.implementationVendor
obs.manifestImplementationVer = signals.implementationVer
case paperMetaMainClassPath:
data, err := entry.read()
if err != nil {
return err
}
obs.metaMainClass = strings.TrimSpace(string(data))
case paperLibrariesListPath:
data, err := entry.read()
if err != nil {
return err
}
obs.librariesListEntries = readObservationLines(data)
case paperVersionsListPath:
data, err := entry.read()
if err != nil {
return err
}
obs.versionsListEntries = readObservationLines(data)
case paperPatchesListPath:
data, err := entry.read()
if err != nil {
return err
}
obs.patchesListEntries = readObservationLines(data)
case paperDownloadContextPath:
data, err := entry.read()
if err != nil {
return err
}
obs.downloadContext = strings.TrimSpace(string(data))
case paperVersionJSONPath:
data, err := entry.read()
if err != nil {
return err
}
obs.versionJSONID = parsePaperVersionJSONID(data)
case paperPatchPropertiesPath:
data, err := entry.read()
if err != nil {
return err
}
obs.patchProperties = parsePaperPatchProperties(data)
obs.hasPaperMCPatch = obs.hasPaperMCPatch || strings.EqualFold(
obs.patchProperties["patch"],
paperPatchFilePath,
)
case paperPatchFilePath:
obs.hasPaperMCPatch = true
case paperBuildInfoPath:
data, err := entry.read()
if err != nil {
return err
}
obs.buildInfo = strings.TrimSpace(string(data))
case paperLeavesclipVersionPath:
data, err := entry.read()
if err != nil {
return err
}
obs.leavesclipVersion = strings.TrimSpace(string(data))
}
return nil
},
)
if err != nil {
return paperObservations{}, err
}
obs.gameVersion = inferPaperObservationGameVersion(obs)
return obs, nil
}
func newPaperObservationSource(
s *scan,
primary *artifact,
) (paperObservationSource, error) {
if primary == nil {
return nil, fmt.Errorf("paper observation artifact is missing")
}
if primary.IsDirectory() {
return paperObservationDirSource{
scan: s,
root: primary.Path(),
}, nil
}
archive, err := primary.Archive()
if err != nil {
return nil, err
}
if archive == nil {
return nil, fmt.Errorf(
"paper observation path is not an archive: %s",
primary.Path(),
)
}
return paperObservationZipSource{reader: archive}, nil
}
type paperObservationZipSource struct {
reader *zip.Reader
}
func (s paperObservationZipSource) Walk(fn func(paperObservationEntry) error) error {
for _, file := range s.reader.File {
if file.FileInfo().IsDir() {
continue
}
zipFile := file
if err := fn(
paperObservationEntry{
name: filepath.ToSlash(zipFile.Name),
read: func() ([]byte, error) {
r, err := zipFile.Open()
if err != nil {
return nil, err
}
defer r.Close()
return fsutil.CopyBytes(r, fsutil.MaxZipEntryBytes)
},
},
); err != nil {
return err
}
}
return nil
}
type paperObservationDirSource struct {
scan *scan
root string
}
func (s paperObservationDirSource) Walk(fn func(paperObservationEntry) error) error {
return filepath.WalkDir(
s.root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(s.root, path)
if err != nil {
return err
}
file, ok := s.scan.File(path)
if !ok || file.IsDirectory() {
return nil
}
return fn(
paperObservationEntry{
name: filepath.ToSlash(rel),
read: file.Read,
},
)
},
)
}
func readObservationLines(data []byte) []string {
trimmed := strings.TrimSpace(string(data))
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "\n")
}
func parsePaperVersionJSONID(data []byte) versionpkg.Version {
var payload struct {
ID string `json:"id"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return versionpkg.Unknown
}
if !isMinecraftReleaseVersion(payload.ID) {
return versionpkg.Unknown
}
return versionpkg.Version(payload.ID)
}
func parsePaperPatchProperties(data []byte) map[string]string {
properties := make(map[string]string)
for _, line := range readObservationLines(data) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
properties[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
return properties
}
func inferPaperObservationGameVersion(obs paperObservations) versionpkg.Version {
if hasConcreteVersion(obs.versionJSONID) {
return obs.versionJSONID
}
if version := parseBukkitGameVersion(obs.manifestImplementationVer); hasConcreteVersion(version) {
return version
}
if version := parsePaperVersionList(obs.versionsListEntries); hasConcreteVersion(version) {
return version
}
if version := parsePaperPatchPropertiesVersion(obs.patchProperties); hasConcreteVersion(version) {
return version
}
return versionpkg.Unknown
}
func parsePaperVersionList(entries []string) versionpkg.Version {
for _, entry := range entries {
fields := strings.Split(entry, "\t")
if len(fields) < 2 {
continue
}
candidate := strings.TrimSpace(fields[1])
if isMinecraftReleaseVersion(candidate) {
return versionpkg.Version(candidate)
}
}
return versionpkg.Unknown
}
func parsePaperPatchPropertiesVersion(properties map[string]string) versionpkg.Version {
version := strings.TrimSpace(properties["version"])
if !isMinecraftReleaseVersion(version) {
return versionpkg.Unknown
}
return versionpkg.Version(version)
}
package discovery
import "strings"
type productPreferenceGroup struct {
names []string
tier int
order int
}
// productPreferences is the fixed live-status preference order: lower tiers,
// then lower orders, win. Names match case-insensitively, and the primary
// runtime name is consulted before the catalogued product string.
var productPreferences = [...]productPreferenceGroup{
// Tier 0: hybrid server platforms.
{names: []string{"youer"}, tier: 0, order: 0},
{names: []string{"arclight", "arclight-forge", "arclight-neoforge", "arclight-fabric"}, tier: 0, order: 1},
{names: []string{"catserver"}, tier: 0, order: 2},
{names: []string{"spongeneo"}, tier: 0, order: 3},
{names: []string{"spongeforge"}, tier: 0, order: 4},
// Tier 1: mod loaders.
{names: []string{"neoforge"}, tier: 1, order: 0},
{names: []string{"forge"}, tier: 1, order: 1},
{names: []string{"fabric"}, tier: 1, order: 2},
// Tier 2: Paper-family forks.
{names: []string{"reaper"}, tier: 2, order: 0},
{names: []string{"leaf"}, tier: 2, order: 1},
{names: []string{"divine"}, tier: 2, order: 2},
{names: []string{"pufferfish"}, tier: 2, order: 3},
{names: []string{"purpur"}, tier: 2, order: 4},
{names: []string{"folia"}, tier: 2, order: 5},
{names: []string{"leaves"}, tier: 2, order: 6},
// Tier 3: general-purpose server implementations.
{names: []string{"paper"}, tier: 3, order: 0},
{names: []string{"spigot"}, tier: 3, order: 1},
{names: []string{"craftbukkit", "bukkit"}, tier: 3, order: 2},
{names: []string{"spongevanilla"}, tier: 3, order: 3},
// Tier 4: vanilla Minecraft.
{names: []string{"minecraft", "vanilla", "mc"}, tier: 4, order: 0},
// Tier 5: proxies.
{names: []string{"bungeecord", "velocity", "waterfall"}, tier: 5, order: 0},
}
const (
productRankFallbackTier = 5
productRankFallbackOrder = 1
)
func productRankByName(name string) (int, int, bool) {
name = strings.TrimSpace(name)
for _, group := range productPreferences {
for _, candidate := range group.names {
if strings.EqualFold(name, candidate) {
return group.tier, group.order, true
}
}
}
return 0, 0, false
}
func productRank(identity Identity) (int, int) {
if tier, order, ok := productRankByName(identity.Primary.Name.String()); ok {
return tier, order
}
if tier, order, ok := productRankByName(identity.Product().String()); ok {
return tier, order
}
return productRankFallbackTier, productRankFallbackOrder
}
package discovery
import (
"archive/zip"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"github.com/mclucy/lucy/internal/fsutil"
)
// scan owns one canonical root, the root-confined filesystem view, and the
// artifact metadata cache for a single Discover call. It is private: no
// resource-bearing value escapes into Result.
type scan struct {
root string
displayRoot string
fs *os.Root
mu sync.Mutex
files map[string]*artifact
physical []physicalArtifact
rootFiles []*artifact
readers []*os.File
closed bool
}
type physicalArtifact struct {
info os.FileInfo
file *artifact
}
// artifact is one cached filesystem entry. Sidecar bytes and ZIP metadata are
// cached lazily; a JAR is never cached as a whole byte slice.
type artifact struct {
scan *scan
path string
relPath string
isDir bool
size int64
dataOnce sync.Once
data []byte
dataErr error
archiveOnce sync.Once
archive *zip.Reader
archiveErr error
}
func newScan(root string) (*scan, error) {
absolute, err := filepath.Abs(root)
if err != nil {
return nil, err
}
resolved, err := filepath.EvalSymlinks(absolute)
if err != nil {
return nil, err
}
info, err := os.Stat(resolved)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("scan root %s is not a directory", resolved)
}
fs, err := os.OpenRoot(resolved)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(resolved)
if err != nil {
_ = fs.Close()
return nil, err
}
s := &scan{
root: resolved,
displayRoot: absolute,
fs: fs,
files: make(map[string]*artifact, len(entries)),
rootFiles: make([]*artifact, 0, len(entries)),
}
rootSeen := make(map[*artifact]bool, len(entries))
for _, entry := range entries {
file, ok := s.fileLocked(entry.Name())
if !ok || rootSeen[file] {
continue
}
rootSeen[file] = true
s.rootFiles = append(s.rootFiles, file)
}
return s, nil
}
// RootFiles returns the cached top-level artifacts. The slice is the scan's
// internal read-only view; callers must not mutate or retain it past the scan.
func (s *scan) RootFiles() []*artifact {
if s == nil {
return nil
}
return s.rootFiles
}
// File resolves an absolute or root-relative path inside the root. Nested
// paths and symlinks are accepted only when their resolved target stays inside
// the root.
func (s *scan) File(path string) (*artifact, bool) {
if s == nil {
return nil, false
}
rel, ok := s.relative(path)
if !ok {
return nil, false
}
s.mu.Lock()
defer s.mu.Unlock()
return s.fileLocked(rel)
}
// fileLocked resolves one path to the single cached artifact that represents
// its physical file. Aliases, hard links, and symlinks that resolve inside the
// root share one artifact, one metadata cache, and one ReaderAt descriptor.
func (s *scan) fileLocked(rel string) (*artifact, bool) {
if file, exists := s.files[rel]; exists {
return file, true
}
if s.closed {
return nil, false
}
linkInfo, err := s.fs.Lstat(rel)
if err != nil {
return nil, false
}
info, statErr := s.fs.Stat(rel)
canonicalRel := rel
if statErr != nil {
// A symlink that escapes the root is not followed. It remains its own
// unreadable artifact so a later read fails instead of leaking data.
info = linkInfo
} else if linkInfo.Mode()&os.ModeSymlink != 0 {
if resolved, resolveErr := filepath.EvalSymlinks(
filepath.Join(s.displayRoot, rel),
); resolveErr == nil {
if target, ok := relativeWithin(s.root, resolved); ok {
canonicalRel = target
} else if target, ok := relativeWithin(s.displayRoot, resolved); ok {
canonicalRel = target
}
}
}
for _, physical := range s.physical {
if !os.SameFile(physical.info, info) {
continue
}
s.files[rel] = physical.file
if canonicalRel != rel {
s.files[canonicalRel] = physical.file
}
return physical.file, true
}
file := &artifact{
scan: s,
path: filepath.Join(s.displayRoot, canonicalRel),
relPath: canonicalRel,
isDir: info.IsDir(),
}
if !info.IsDir() {
file.size = info.Size()
}
s.physical = append(s.physical, physicalArtifact{info: info, file: file})
s.files[rel] = file
if canonicalRel != rel {
s.files[canonicalRel] = file
}
return file, true
}
// Sibling resolves a named sibling of primary inside the root.
func (s *scan) Sibling(primary *artifact, name string) (*artifact, bool) {
if s == nil || primary == nil || name == "" {
return nil, false
}
return s.File(filepath.Join(filepath.Dir(primary.RelPath()), name))
}
// ReadDir lists a root-confined directory in lexical order.
func (s *scan) ReadDir(path string) ([]os.DirEntry, error) {
if s == nil {
return nil, os.ErrInvalid
}
rel, ok := s.relative(path)
if !ok {
return nil, fmt.Errorf("path %q is outside the scan root", path)
}
s.mu.Lock()
closed := s.closed
s.mu.Unlock()
if closed {
return nil, os.ErrClosed
}
file, err := s.fs.Open(rel)
if err != nil {
return nil, err
}
defer file.Close()
entries, err := file.ReadDir(-1)
if err != nil {
return nil, err
}
slices.SortFunc(entries, func(left, right os.DirEntry) int {
return strings.Compare(left.Name(), right.Name())
})
return entries, nil
}
// Close releases every ReaderAt descriptor and the root handle.
func (s *scan) Close() error {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
s.closed = true
var problems []error
for _, reader := range s.readers {
if err := reader.Close(); err != nil {
problems = append(problems, err)
}
}
s.readers = nil
if s.fs != nil {
if err := s.fs.Close(); err != nil {
problems = append(problems, err)
}
s.fs = nil
}
return errors.Join(problems...)
}
func (s *scan) relative(path string) (string, bool) {
if path == "" {
return "", false
}
clean := filepath.Clean(path)
if filepath.IsAbs(clean) {
if rel, ok := relativeWithin(s.displayRoot, clean); ok {
return rel, true
}
if rel, ok := relativeWithin(s.root, clean); ok {
return rel, true
}
resolved, err := filepath.EvalSymlinks(clean)
if err == nil {
if rel, ok := relativeWithin(s.root, resolved); ok {
return rel, true
}
}
return "", false
}
if clean == "." || !filepath.IsLocal(clean) {
return "", false
}
return clean, true
}
func relativeWithin(root, path string) (string, bool) {
rel, err := filepath.Rel(root, path)
if err != nil || rel == "." || !filepath.IsLocal(rel) {
return "", false
}
return rel, true
}
func (s *scan) openReader(file *artifact) (*os.File, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil, os.ErrClosed
}
reader, err := s.fs.Open(file.relPath)
if err != nil {
return nil, err
}
s.readers = append(s.readers, reader)
return reader, nil
}
// Path returns the absolute canonical path.
func (a *artifact) Path() string {
if a == nil {
return ""
}
return a.path
}
// RelPath returns the path relative to the scan root using slashes.
func (a *artifact) RelPath() string {
if a == nil {
return ""
}
return filepath.ToSlash(a.relPath)
}
// IsDirectory reports whether the artifact is a directory.
func (a *artifact) IsDirectory() bool {
return a != nil && a.isDir
}
// Read returns bounded sidecar bytes. JAR/ZIP payloads must be opened through
// Archive so a large server JAR is never cached as a whole byte slice.
func (a *artifact) Read() ([]byte, error) {
if a == nil {
return nil, os.ErrInvalid
}
a.dataOnce.Do(func() {
if a.isDir {
a.dataErr = errNotRegularFile
return
}
if isArchivePath(a.relPath) {
a.dataErr = fmt.Errorf(
"artifact %s is an archive; open it through Archive",
a.RelPath(),
)
return
}
if a.size > fsutil.MaxZipEntryBytes {
a.dataErr = fmt.Errorf(
"artifact %s exceeds the sidecar read limit",
a.RelPath(),
)
return
}
if a.scan == nil || a.scan.fs == nil {
a.dataErr = os.ErrClosed
return
}
reader, err := a.scan.fs.Open(a.relPath)
if err != nil {
a.dataErr = err
return
}
defer func() {
if err := reader.Close(); err != nil {
a.dataErr = err
}
}()
a.data, a.dataErr = fsutil.CopyBytes(reader, fsutil.MaxZipEntryBytes)
})
return a.data, a.dataErr
}
// Archive opens the artifact as a ZIP reader backed by a scan-owned ReaderAt
// descriptor. The descriptor stays open until scan.Close.
func (a *artifact) Archive() (*zip.Reader, error) {
if a == nil {
return nil, os.ErrInvalid
}
a.archiveOnce.Do(func() {
if a.isDir {
a.archiveErr = fmt.Errorf("artifact %s is a directory", a.RelPath())
return
}
if a.scan == nil {
a.archiveErr = os.ErrInvalid
return
}
reader, err := a.scan.openReader(a)
if err != nil {
a.archiveErr = err
return
}
info, err := reader.Stat()
if err != nil {
a.archiveErr = err
return
}
a.archive, a.archiveErr = zip.NewReader(reader, info.Size())
})
return a.archive, a.archiveErr
}
func isArchivePath(path string) bool {
lower := strings.ToLower(path)
return strings.HasSuffix(lower, ".jar") || strings.HasSuffix(lower, ".zip")
}
package discovery
import (
"os"
"path/filepath"
"slices"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/server"
versionpkg "github.com/mclucy/lucy/version"
)
// SelectionState distinguishes the explicit outcomes of a selection request.
type SelectionState string
const (
SelectionUnknown SelectionState = ""
SelectionNone SelectionState = "none"
SelectionSelected SelectionState = "selected"
SelectionAmbiguous SelectionState = "ambiguous"
SelectionMissingDeclared SelectionState = "missing_declared"
SelectionInvalid SelectionState = "invalid"
)
// SelectionSource records why an installation was selected.
type SelectionSource string
const (
SourceUnknown SelectionSource = ""
SourceInferred SelectionSource = "inferred"
SourceDeclared SelectionSource = "declared"
SourceExplicit SelectionSource = "explicit"
SourcePreferred SelectionSource = "preferred"
)
// SelectionPolicy controls whether an ambiguous observation may be resolved
// by the read-only preference order.
type SelectionPolicy string
const (
PolicyUnknown SelectionPolicy = ""
PolicyStrict SelectionPolicy = "strict"
PolicyPreferModified SelectionPolicy = "prefer_modified"
)
// Declaration is the applicable persisted installation identity.
type Declaration struct {
Product server.Product
Minecraft versionpkg.Version
CoreVersion versionpkg.Version
}
// Request is one selection attempt. A nonempty explicit EntryPoint overrides
// the declaration. PolicyPreferModified is opted into only by live status.
type Request struct {
EntryPoint string
Declared *Declaration
Policy SelectionPolicy
}
// Selection is the read-only result of a selection attempt. It references the
// chosen installation and one actual entry-point member.
type Selection struct {
State SelectionState
Source SelectionSource
Installation *Installation
EntryPoint *File
RequestedPath string
}
// IsValid reports whether the selection names a chosen installation and a
// real entry-point member with a usable identity.
func (s Selection) IsValid() bool {
return s.State == SelectionSelected &&
s.Installation != nil &&
s.Installation.Identity.IsValid() &&
s.EntryPoint != nil &&
s.EntryPoint.Role == RoleEntryPoint &&
s.EntryPoint.Path != ""
}
// Product returns the selected installation product.
func (s Selection) Product() server.Product {
if !s.IsValid() {
return server.ProductUnknown
}
return s.Installation.Identity.Product()
}
// MinecraftVersion returns the selected installation Minecraft version.
func (s Selection) MinecraftVersion() versionpkg.Version {
if !s.IsValid() {
return versionpkg.Unknown
}
return s.Installation.Identity.MinecraftVersion()
}
// Ecosystem returns the selected installation loader ecosystem.
func (s Selection) Ecosystem() catalog.Ecosystem {
if !s.IsValid() {
return catalog.EcosystemUnspecified
}
return s.Installation.Identity.Ecosystem()
}
// Select updates the result selection without mutating or reordering members.
// It never writes files. Every non-selected outcome returns *SelectionError.
func (r *Result) Select(request Request) error {
if r == nil {
return &SelectionError{State: SelectionNone}
}
r.Selection = Selection{
State: SelectionNone,
RequestedPath: request.EntryPoint,
}
selectable := r.selectableInstallations()
if request.EntryPoint != "" {
return r.selectExplicit(request.EntryPoint, selectable)
}
if request.Declared != nil {
return r.selectDeclared(request.Declared, selectable)
}
return r.selectInferred(request.Policy, selectable)
}
func (r *Result) selectExplicit(path string, selectable []*Installation) error {
matches := installationsWithEntryPoint(selectable, path)
switch len(matches) {
case 1:
file := entryPointAt(matches[0], path)
if file == nil {
break
}
r.selectInstallation(matches[0], file, SourceExplicit)
return nil
case 0:
default:
r.Selection.State = SelectionInvalid
return &SelectionError{
State: SelectionInvalid,
Path: path,
Installations: cloneInstallations(matches),
}
}
r.Selection.State = SelectionInvalid
return &SelectionError{
State: SelectionInvalid,
Path: path,
Installations: cloneInstallations(selectable),
}
}
func (r *Result) selectDeclared(
declaration *Declaration,
selectable []*Installation,
) error {
matches := installationsMatchingDeclaration(selectable, declaration)
switch len(matches) {
case 1:
if file := chooseEntryPoint(matches[0]); file != nil {
r.selectInstallation(matches[0], file, SourceDeclared)
return nil
}
case 0:
default:
r.Selection.State = SelectionAmbiguous
return &SelectionError{
State: SelectionAmbiguous,
Installations: cloneInstallations(matches),
}
}
r.Selection.State = SelectionMissingDeclared
return &SelectionError{
State: SelectionMissingDeclared,
Installations: cloneInstallations(selectable),
}
}
func (r *Result) selectInferred(
policy SelectionPolicy,
selectable []*Installation,
) error {
switch len(selectable) {
case 0:
if len(r.Installations) > 0 {
r.Selection.State = SelectionInvalid
return &SelectionError{
State: SelectionInvalid,
Installations: copyInstallations(r.Installations),
}
}
r.Selection.State = SelectionNone
return &SelectionError{State: SelectionNone}
case 1:
if file := chooseEntryPoint(selectable[0]); file != nil {
r.selectInstallation(selectable[0], file, SourceInferred)
return nil
}
default:
if policy == PolicyPreferModified {
if preferred := preferredInstallation(selectable); preferred != nil {
if file := chooseEntryPoint(preferred); file != nil {
r.selectInstallation(preferred, file, SourcePreferred)
return nil
}
}
}
}
r.Selection.State = SelectionAmbiguous
return &SelectionError{
State: SelectionAmbiguous,
Installations: cloneInstallations(selectable),
}
}
func (r *Result) selectInstallation(
installation *Installation,
entryPoint *File,
source SelectionSource,
) {
r.Selection.State = SelectionSelected
r.Selection.Source = source
r.Selection.Installation = installation
r.Selection.EntryPoint = entryPoint
}
func (r *Result) selectableInstallations() []*Installation {
selectable := make([]*Installation, 0, len(r.Installations))
for index := range r.Installations {
installation := &r.Installations[index]
if !selectableInstallation(installation) {
continue
}
selectable = append(selectable, installation)
}
return selectable
}
// A known version conflict excludes an installation. Missing metadata does not
// establish a conflict or prove equality between candidates; selection still
// requires one matching installation.
func installationsMatchingDeclaration(
installations []*Installation,
declaration *Declaration,
) []*Installation {
if declaration == nil || declaration.Product == server.ProductUnknown {
return nil
}
matches := make([]*Installation, 0, len(installations))
for _, installation := range installations {
if !installation.Identity.IsValid() {
continue
}
if !installationMatchesProduct(installation, declaration.Product) {
continue
}
if concreteVersion(declaration.Minecraft) {
observed := installation.Identity.MinecraftVersion()
if concreteVersion(observed) && observed != declaration.Minecraft {
continue
}
}
if concreteVersion(declaration.CoreVersion) {
observed := installation.Identity.Primary.Version
if concreteVersion(observed) && observed != declaration.CoreVersion {
continue
}
}
matches = append(matches, installation)
}
return matches
}
// installationMatchesProduct matches the catalogued product first. Products
// outside the server catalog, such as a Paper fork named only by its primary
// identity, match by canonical primary name.
func installationMatchesProduct(
installation *Installation,
product server.Product,
) bool {
if installation == nil || product == server.ProductUnknown {
return false
}
if installation.Identity.Product() == product {
return true
}
return strings.EqualFold(
installation.Identity.Primary.Name.String(),
string(product),
)
}
func installationsWithEntryPoint(
installations []*Installation,
path string,
) []*Installation {
matches := make([]*Installation, 0, 1)
for _, installation := range installations {
if entryPointAt(installation, path) == nil {
continue
}
matches = append(matches, installation)
}
return matches
}
func entryPointAt(installation *Installation, path string) *File {
if installation == nil {
return nil
}
for file := range installation.EntryPoints() {
if samePath(file.Path, path) {
return file
}
}
return nil
}
func firstEntryPoint(installation *Installation) *File {
if installation == nil {
return nil
}
for file := range installation.EntryPoints() {
return file
}
return nil
}
// chooseEntryPoint prefers a receipt-managed entry point, then the lexical
// first entry point. Filesystem timestamps never participate.
func chooseEntryPoint(installation *Installation) *File {
if installation == nil {
return nil
}
var first, managed *File
for file := range installation.EntryPoints() {
if first == nil || file.Path < first.Path {
first = file
}
if file.Ownership == OwnershipManaged &&
(managed == nil || file.Path < managed.Path) {
managed = file
}
}
if managed != nil {
return managed
}
return first
}
func hasManagedEntryPoint(installation *Installation) bool {
for file := range installation.EntryPoints() {
if file.Ownership == OwnershipManaged {
return true
}
}
return false
}
func preferredInstallation(candidates []*Installation) *Installation {
if len(candidates) == 0 {
return nil
}
best := candidates[0]
for _, candidate := range candidates[1:] {
if preferInstallation(candidate, best) < 0 {
best = candidate
}
}
return best
}
func preferInstallation(left, right *Installation) int {
leftTier, leftOrder := productRank(left.Identity)
rightTier, rightOrder := productRank(right.Identity)
if leftTier != rightTier {
return leftTier - rightTier
}
if leftOrder != rightOrder {
return leftOrder - rightOrder
}
leftManaged := hasManagedEntryPoint(left)
rightManaged := hasManagedEntryPoint(right)
if leftManaged != rightManaged {
if leftManaged {
return -1
}
return 1
}
if result := strings.Compare(identityKey(left.Identity), identityKey(right.Identity)); result != 0 {
return result
}
return strings.Compare(firstEntryPointPath(left), firstEntryPointPath(right))
}
func firstEntryPointPath(installation *Installation) string {
if installation == nil {
return ""
}
path := ""
for file := range installation.EntryPoints() {
if path == "" || file.Path < path {
path = file.Path
}
}
return path
}
func cloneInstallations(installations []*Installation) []Installation {
cloned := make([]Installation, 0, len(installations))
for _, installation := range installations {
if installation == nil {
continue
}
cloned = append(cloned, *installation)
}
return cloned
}
func copyInstallations(installations []Installation) []Installation {
return slices.Clone(installations)
}
// samePath compares canonical paths, resolving symlinks and identity when the
// filesystem can prove they name the same object.
func samePath(left, right string) bool {
if left == "" || right == "" {
return false
}
if filepath.Clean(left) == filepath.Clean(right) {
return true
}
leftInfo, leftErr := os.Stat(left)
rightInfo, rightErr := os.Stat(right)
if leftErr == nil && rightErr == nil {
return os.SameFile(leftInfo, rightInfo)
}
leftResolved, leftErr := filepath.EvalSymlinks(left)
rightResolved, rightErr := filepath.EvalSymlinks(right)
return leftErr == nil && rightErr == nil &&
filepath.Clean(leftResolved) == filepath.Clean(rightResolved)
}
package discovery
import (
"bufio"
"path/filepath"
"regexp"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/input"
versionpkg "github.com/mclucy/lucy/version"
)
var (
spongeVanillaVersionPattern = regexp.MustCompile(
`^(\d+\.\d+(?:\.\d+)?)-(\d+(?:\.\d+)+)$`,
)
spongeHybridVersionPattern = regexp.MustCompile(
`^(\d+\.\d+(?:\.\d+)?)-(\d+(?:\.\d+)+)-(\d+(?:\.\d+)+)$`,
)
spongeUniversalJarPattern = regexp.MustCompile(
`(?i)^sponge(?:vanilla|forge|neo)-.+\.jar$`,
)
)
type spongeFlavor int
const (
spongeFlavorUnknown spongeFlavor = iota
spongeFlavorVanilla
spongeFlavorForge
spongeFlavorNeo
)
// discoverSponge recognizes SpongeVanilla/SpongeForge/SpongeNeo universal
// jars. The specification title decides the flavor; the implementation title
// only fills in when no specification title is present.
func discoverSponge(s *scan) ([]Installation, []Finding) {
var installations []Installation
var findings []Finding
for _, primary := range s.RootFiles() {
if !productCandidate(s, primary) {
continue
}
installation, err := inspectSpongeInstallation(s, primary)
if err != nil {
findings = append(findings, Finding{
Kind: FindingUnreadableFile,
Path: primary.Path(),
Cause: err,
})
continue
}
if installation != nil {
installations = append(installations, *installation)
}
}
return installations, findings
}
func inspectSpongeInstallation(
s *scan,
primary *artifact,
) (*Installation, error) {
if !spongeUniversalJarPattern.MatchString(
strings.ToLower(filepath.Base(primary.Path())),
) {
return nil, nil
}
manifest, ok, err := readProductEntry(s, primary, bukkitManifestPath)
if err != nil {
return nil, err
}
if !ok {
return nil, nil
}
signals := parseSpongeManifest(manifest)
if !signals.valid() {
return nil, nil
}
identity := buildSpongeIdentity(signals)
installation := NewInstallation(identity, primary.Path())
return &installation, nil
}
type spongeManifestSignals struct {
title string
vendor string
version string
flavor spongeFlavor
gameVersion versionpkg.Version
loaderVersion versionpkg.Version
spongeVersion versionpkg.Version
}
func (s spongeManifestSignals) valid() bool {
if !strings.EqualFold(strings.TrimSpace(s.vendor), "SpongePowered") {
return false
}
if s.flavor == spongeFlavorUnknown {
return false
}
if !hasConcreteVersion(s.gameVersion) {
return false
}
if !hasConcreteVersion(s.spongeVersion) {
return false
}
if s.flavor != spongeFlavorVanilla && !hasConcreteVersion(s.loaderVersion) {
return false
}
return true
}
func parseSpongeManifest(data []byte) spongeManifestSignals {
var signals spongeManifestSignals
scanner := bufio.NewScanner(strings.NewReader(string(data)))
inNamedSection := false
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "Name: ") {
inNamedSection = true
continue
}
if strings.TrimSpace(line) == "" {
inNamedSection = false
continue
}
if inNamedSection {
continue
}
switch {
case strings.HasPrefix(line, "Specification-Title: "):
signals.title = strings.TrimSpace(
strings.TrimPrefix(line, "Specification-Title: "),
)
case strings.HasPrefix(line, "Specification-Vendor: "):
if signals.vendor == "" {
signals.vendor = strings.TrimSpace(
strings.TrimPrefix(line, "Specification-Vendor: "),
)
}
case strings.HasPrefix(line, "Implementation-Title: "):
if signals.title == "" {
signals.title = strings.TrimSpace(
strings.TrimPrefix(line, "Implementation-Title: "),
)
}
case strings.HasPrefix(line, "Implementation-Vendor: "):
if signals.vendor == "" {
signals.vendor = strings.TrimSpace(
strings.TrimPrefix(line, "Implementation-Vendor: "),
)
}
case strings.HasPrefix(line, "Implementation-Version: "):
if signals.version == "" {
signals.version = strings.TrimSpace(
strings.TrimPrefix(line, "Implementation-Version: "),
)
}
}
}
signals.flavor, signals.gameVersion, signals.loaderVersion, signals.spongeVersion = parseSpongeImplementationVersion(
signals.title,
signals.version,
)
return signals
}
func parseSpongeImplementationVersion(
title string,
version string,
) (
flavor spongeFlavor,
gameVersion versionpkg.Version,
loaderVersion versionpkg.Version,
spongeVersion versionpkg.Version,
) {
version = strings.TrimSpace(version)
switch strings.TrimSpace(title) {
case "SpongeVanilla":
match := spongeVanillaVersionPattern.FindStringSubmatch(version)
if match == nil {
return spongeFlavorUnknown, versionpkg.Unknown, versionpkg.Unknown, versionpkg.Unknown
}
return spongeFlavorVanilla,
versionpkg.Version(match[1]),
versionpkg.Unknown,
versionpkg.Version(match[2])
case "SpongeForge":
match := spongeHybridVersionPattern.FindStringSubmatch(version)
if match == nil {
return spongeFlavorUnknown, versionpkg.Unknown, versionpkg.Unknown, versionpkg.Unknown
}
return spongeFlavorForge,
versionpkg.Version(match[1]),
versionpkg.Version(match[2]),
versionpkg.Version(match[3])
case "SpongeNeo":
match := spongeHybridVersionPattern.FindStringSubmatch(version)
if match == nil {
return spongeFlavorUnknown, versionpkg.Unknown, versionpkg.Unknown, versionpkg.Unknown
}
return spongeFlavorNeo,
versionpkg.Version(match[1]),
versionpkg.Version(match[2]),
versionpkg.Version(match[3])
default:
return spongeFlavorUnknown, versionpkg.Unknown, versionpkg.Unknown, versionpkg.Unknown
}
}
func buildSpongeIdentity(signals spongeManifestSignals) Identity {
primaryName := "spongevanilla"
switch signals.flavor {
case spongeFlavorForge:
primaryName = "spongeforge"
case spongeFlavorNeo:
primaryName = "spongeneo"
}
components := []catalog.VersionedRef{
{
Ecosystem: catalog.EcosystemMinecraft,
Name: input.ToProjectName("minecraft"),
Version: signals.gameVersion,
},
}
switch signals.flavor {
case spongeFlavorForge:
components = append(
components, catalog.VersionedRef{
Ecosystem: catalog.EcosystemForge,
Name: input.ToProjectName("forge"),
Version: signals.loaderVersion,
},
)
case spongeFlavorNeo:
components = append(
components, catalog.VersionedRef{
Ecosystem: catalog.EcosystemNeoForge,
Name: input.ToProjectName("neoforge"),
Version: signals.loaderVersion,
},
)
}
return Identity{
Primary: catalog.VersionedRef{
Ecosystem: catalog.EcosystemSponge,
Name: input.ToProjectName(primaryName),
Version: signals.spongeVersion,
},
Components: components,
}
}
package discovery
import (
"archive/zip"
"encoding/json/v2"
"github.com/mclucy/lucy/internal/fileschema"
"github.com/mclucy/lucy/internal/fsutil"
"github.com/mclucy/lucy/version"
)
// mojangVersionJSONEntry is the version spec entry inside a Mojang server jar.
const mojangVersionJSONEntry = "version.json"
// mojangVersionFromJSON extracts the game version from a Mojang version.json
// payload. guarded reports a Forge installer jar, which also carries a
// version.json but with _comment and/or mainClass control fields a real spec
// never has. encoding/json ignores unknown fields, so the guard fields must
// be checked explicitly.
func mojangVersionFromJSON(data []byte) (version.Version, bool, error) {
guard := &struct {
Comment []string `json:"_comment"`
MainClass string `json:"mainClass"`
}{}
if err := json.Unmarshal(data, guard); err == nil &&
(len(guard.Comment) > 0 || guard.MainClass != "") {
return version.Unknown, true, nil
}
var spec fileschema.FileMinecraftVersionSpec
if err := json.Unmarshal(data, &spec); err != nil {
return version.Unknown, false, err
}
return version.Version(spec.ID), false, nil
}
func readArchiveEntry(zipReader *zip.Reader, name string) ([]byte, bool, error) {
return fsutil.ReadZipEntry(zipReader, name, fsutil.MaxZipEntryBytes)
}
package discovery
import (
"github.com/mclucy/lucy/catalog"
)
// discoverVanilla recognizes Mojang server jars. A Fabric meta launcher also
// embeds version.json; it is rejected here because the loader installation
// owns that artifact, not plain Minecraft.
func discoverVanilla(s *scan) ([]Installation, []Finding) {
var installations []Installation
var findings []Finding
for _, primary := range s.RootFiles() {
if !productCandidate(s, primary) {
continue
}
installation, err := inspectVanillaInstallation(s, primary)
if err != nil {
findings = append(findings, Finding{
Kind: FindingUnreadableFile,
Path: primary.Path(),
Cause: err,
})
continue
}
if installation != nil {
installations = append(installations, *installation)
}
}
return installations, findings
}
func inspectVanillaInstallation(
s *scan,
primary *artifact,
) (*Installation, error) {
launchProperties, hasLaunchProperties, err := readProductEntry(
s,
primary,
fabricLaunchPropertiesPath,
)
if err != nil {
return nil, err
}
if hasLaunchProperties && fabricLauncherPropertiesAreServer(launchProperties) {
return nil, nil
}
versionJSON, ok, err := readProductEntry(s, primary, mojangVersionJSONEntry)
if err != nil {
return nil, err
}
if !ok {
return nil, nil
}
gameVersion, guarded, err := mojangVersionFromJSON(versionJSON)
if err != nil {
return nil, err
}
if guarded {
return nil, nil
}
identity := Identity{
Primary: catalog.VersionedRef{
Ecosystem: catalog.EcosystemMinecraft,
Name: catalog.Name("minecraft"),
Version: observedVersion(gameVersion),
},
}
installation := NewInstallation(identity, primary.Path())
return &installation, nil
}
// Package identify associates local package files with their upstream identities.
package identify
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/upstream"
"github.com/mclucy/lucy/upstream/routing"
"github.com/mclucy/lucy/version"
"github.com/mclucy/lucy/workspace"
)
// HashLookup resolves an artifact fingerprint through the configured
// upstream catalogue. Callers inject the routing capability they own.
type HashLookup func(
ctx context.Context,
artifact upstream.ArtifactFingerprint,
) routing.ArtifactLookup
// Packages adds upstream identities for observed.UnidentifiedPackageFiles.
// It preserves descriptor-derived packages and dependencies, updates the same
// observation, and returns it. Unmatched paths remain. Provider failures
// include file context and are returned even when a later provider identifies
// the file. A non-empty candidate set requires lookup.
func Packages(
ctx context.Context,
observed *workspace.Workspace,
lookup HashLookup,
) (*workspace.Workspace, []error) {
if observed == nil {
return nil, nil
}
candidates := observed.UnidentifiedPackageFiles
if len(candidates) == 0 {
return observed, nil
}
if lookup == nil {
return observed, []error{errors.New("package identification requires a hash lookup")}
}
// Each worker owns one slot. Merge in candidate order after all lookups finish.
lookups := make([]routing.ArtifactLookup, len(candidates))
var wg sync.WaitGroup
for i, path := range candidates {
wg.Go(func() {
lookups[i] = lookup(ctx, artifact.File{Path: path})
})
}
wg.Wait()
var (
idx *workspace.PackageIndex
unidentified []string
errs []error
)
for i, lookup := range lookups {
path := candidates[i]
if lookup.Matched() {
if idx == nil {
idx = workspace.NewPackageIndex()
idx.Merge(observed.Packages)
}
idx.Add(identifiedPackage(path, lookup.Ref))
} else {
unidentified = append(unidentified, path)
}
for _, providerErr := range lookup.Errors {
errs = append(errs, fmt.Errorf("%s: %w", path, providerErr))
}
}
if idx != nil {
observed.Packages = idx.Packages()
}
observed.UnidentifiedPackageFiles = unidentified
return observed, errs
}
func identifiedPackage(
path string,
ref catalog.VersionedRef,
) workspace.DiscoveredPackage {
ecosystem := ref.Ecosystem
if ecosystem == catalog.EcosystemUnspecified {
ecosystem = catalog.EcosystemForge
}
resolved := ref.Version
if resolved == "" {
resolved = version.Unknown
}
name := ref.Name
if ref.Provider == catalog.ProviderMCDR {
name = catalog.Name(
strings.ReplaceAll(string(ref.Name), "_", "-"),
)
}
return workspace.DiscoveredPackage{
ID: catalog.VersionedRef{
Name: name,
Provider: ref.Provider,
Ecosystem: ecosystem,
Version: resolved,
},
Path: path,
}
}
// Package workspace observes a Minecraft server directory (i.e., a workspace).
//
// The package collects the following information:
//
// - the server runtime that runs in the directory
// - the directories that hold content packages
// - the content packages that are installed
// - the running state of the server process
//
// Observe returns a Workspace pointer. A Workspace is one local observation:
// ServerDir, Wrappers, Discovery, Packages, UnidentifiedPackageFiles, and
// Warnings store the facts, and every other member is a method over them.
//
// Observation is local-only: the package reads the server directory and the
// installed artifacts, and never contacts upstream services. Resolving the
// upstream identity of a package is a separate step.
package workspace
import (
"errors"
"path/filepath"
"github.com/mclucy/lucy/document"
versionpkg "github.com/mclucy/lucy/version"
"github.com/mclucy/lucy/workspace/discovery"
)
const sessionLockName = "session.lock"
// Workspace is one consistent observation of a server directory.
type Workspace struct {
// ServerDir is the server directory that the observation describes.
// If MCDR manages the anchor, then ServerDir is the MCDR working
// directory.
ServerDir string `json:"root"`
Wrappers WrapperInfo `json:"wrappers"`
// Warnings records the recoverable problems seen while observing the
// directory, such as MCDR version-detection failures. It stays out of
// the JSON wire format; CLI presentation reports the entries once per
// consumed observation.
Warnings []error `json:"-"`
// Discovery holds the observed installations, ownership diagnostics,
// and the runtime selected from the request and the applicable
// declaration. Presentation projects this result explicitly.
Discovery discovery.Result `json:"-"`
// Packages contains identities associated with installed package files.
// Observation fills it from descriptors; identification can add hash matches.
Packages []DiscoveredPackage `json:"packages"`
// UnidentifiedPackageFiles lists JAR paths without a known package identity.
// A missing or unreadable descriptor does not establish that a file is a
// valid package.
UnidentifiedPackageFiles []string `json:"-"`
// declared is the applicable declaration derived from the manifest and
// lock, if any. Later selections keep its authority.
declared *discovery.Declaration
// selectionErr records why the current selection is not valid. A nil
// error means the observation selected a runtime.
selectionErr error
}
// Observe builds one observation of root. The request carries an explicit
// entry point and the selection policy. Observe derives the applicable
// declaration from the local manifest and lock itself, discovers
// installations, verifies receipt ownership, selects a runtime, and then
// observes the package directories of the selected runtime.
//
// Selection happens before package observation so package directories always
// belong to the runtime that the observation reports.
func Observe(root string, request discovery.Request) *Workspace {
return observe(root, request, true)
}
// observe is the shared observation pipeline. withPackages is false only for
// activity checks, which need the selected runtime but not package files.
func observe(root string, request discovery.Request, withPackages bool) *Workspace {
anchor, err := filepath.Abs(root)
if err != nil {
return &Workspace{}
}
wrappers, warnings := detectWrappers(anchor)
declaration, locked := applicableDeclaration(anchor)
ws := &Workspace{
ServerDir: resolveServerDir(anchor, wrappers),
Wrappers: wrappers,
Warnings: warnings,
declared: declaration,
}
if locked != nil {
ws.ServerDir = filepath.Join(anchor, filepath.FromSlash(locked.Server.Directory))
}
ws.Discovery = discovery.Discover(ws.ServerDir)
ws.Discovery.ObserveOwnership(anchor)
request.Declared = declaration
if request.EntryPoint != "" && !filepath.IsAbs(request.EntryPoint) {
request.EntryPoint = filepath.Join(anchor, request.EntryPoint)
}
ws.selectionErr = ws.Discovery.Select(request)
if withPackages {
ws.observePackages()
}
return ws
}
// Select changes the runtime selection of this observation and replaces the
// package observation with the directories that the resulting selection names.
// Package data therefore never describes another selection, including when the
// attempt selects no runtime. Select preserves the declaration derived during
// observation and never rescans installations.
func (ws *Workspace) Select(request discovery.Request) error {
if ws == nil {
return errors.New("workspace: no observation")
}
if request.Declared == nil {
request.Declared = ws.declared
}
ws.selectionErr = ws.Discovery.Select(request)
ws.observePackages()
return ws.selectionErr
}
// SelectionError reports why this observation has no valid runtime selection.
// It is nil when a runtime was selected.
func (ws *Workspace) SelectionError() error {
if ws == nil {
return nil
}
return ws.selectionErr
}
// observePackages observes the package directories of the current selection
// and replaces Packages and UnidentifiedPackageFiles. An unresolved selection
// leaves only the wrapper-owned package directories.
func (ws *Workspace) observePackages() {
if ws == nil {
return
}
packages, unidentified, warnings := discoverPackages(
ws.PackageDirs(),
mcdrPluginDirs(ws.Wrappers),
)
ws.Packages, ws.UnidentifiedPackageFiles = packages, unidentified
ws.Warnings = append(ws.Warnings, warnings...)
}
// applicableDeclaration builds the runtime declaration from a readable
// manifest and a valid lock whose runtime identity agrees with the manifest.
// Package-only intent changes do not invalidate runtime authority.
func applicableDeclaration(
root string,
) (*discovery.Declaration, *document.Lock) {
declared, err := document.ManifestFile(root).Read()
if err != nil {
return nil, nil
}
locked, err := document.LockFile(root).Read()
if err != nil {
return nil, nil
}
if declared.Server.Directory != locked.Server.Directory ||
declared.Server.Minecraft != locked.Server.Minecraft ||
declared.Server.Core.Product != locked.Server.Product ||
(concreteRuntimeVersion(declared.Server.Core.ProductVersion) &&
declared.Server.Core.ProductVersion != locked.Server.CoreVersion) {
return nil, nil
}
return &discovery.Declaration{
Product: locked.Server.Product,
Minecraft: locked.Server.Minecraft,
CoreVersion: locked.Server.CoreVersion,
}, locked
}
// resolveServerDir returns the directory that holds the server. Only MCDR
// moves the server away from the anchor. MCDR uses its configured working
// directory.
func resolveServerDir(anchor string, wrappers WrapperInfo) string {
if wrappers.MCDR != nil {
wd := wrappers.MCDR.Config.WorkingDirectory
if filepath.IsAbs(wd) {
return wd
}
return filepath.Join(anchor, wd)
}
return anchor
}
func concreteRuntimeVersion(version versionpkg.Version) bool {
switch version {
case "", versionpkg.None, versionpkg.Unknown, versionpkg.Any,
versionpkg.Stable, versionpkg.Beta:
return false
default:
return true
}
}
func mcdrPluginDirs(wrappers WrapperInfo) []string {
if wrappers.MCDR == nil {
return nil
}
return wrappers.MCDR.Config.PluginDirectories
}
// Runtime returns the selected runtime, or nil when the observation did not
// settle on one. Every non-nil result satisfies IsValid.
func (ws *Workspace) Runtime() *discovery.Selection {
if ws == nil {
return nil
}
return ws.Discovery.Selected()
}
// Running reports whether the server runs at this moment. The check probes
// an exclusive lock on the session file. Running performs I/O on every call,
// because activity changes over time. Running returns false when no single
// runtime exists, or when the save location is unknown.
func (ws *Workspace) Running() bool {
if ws == nil || ws.Runtime() == nil {
return false
}
saveDir := ws.SaveDir()
if saveDir == "" {
return false
}
return checkSessionLock(filepath.Join(saveDir, sessionLockName))
}
package workspace
import (
"os"
"path/filepath"
"github.com/mclucy/lucy/workspace/discovery"
"gopkg.in/yaml.v3"
)
// RunningAt checks the native world session lock without catalogue or app lookup.
func RunningAt(anchor string) bool {
root := anchor
if raw, err := os.ReadFile(filepath.Join(anchor, "config.yml")); err == nil {
var config struct {
WorkingDirectory string `yaml:"working_directory"`
}
if yaml.Unmarshal(raw, &config) == nil && config.WorkingDirectory != "" {
root = config.WorkingDirectory
if !filepath.IsAbs(root) {
root = filepath.Join(anchor, root)
}
}
}
ws := &Workspace{ServerDir: root}
ws.Discovery = discovery.Discover(root)
ws.selectionErr = ws.Discovery.Select(discovery.Request{})
return ws.Running()
}
package workspace
import (
"slices"
"strings"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/workspace/discovery"
)
// ServedEcosystem is one ecosystem that a runtime can serve with a
// compatibility note.
type ServedEcosystem struct {
Ecosystem catalog.Ecosystem `json:"ecosystem"`
Compatibility Compatibility `json:"compatibility"`
}
// ServedEcosystems lists the package ecosystems an observed workspace
// can serve. The list contains:
//
// - direct ecosystems from the detected runtime
// - bridged ecosystems through installed packages. Example: Sinytra
// Connector
//
// Consumers should call this method. selectionServedEcosystems gives only the
// direct ecosystems.
func (ws *Workspace) ServedEcosystems() []ServedEcosystem {
if ws == nil {
return nil
}
offers := selectionServedEcosystems(ws.Runtime())
if hasDirectOffer(offers, catalog.EcosystemForge) ||
hasDirectOffer(offers, catalog.EcosystemNeoForge) {
for _, pkg := range ws.Packages {
if !isConnectorBridgePackage(pkg) {
continue
}
offers = appendServedEcosystem(
offers,
catalog.EcosystemFabric,
CompatDegraded,
)
break
}
}
return offers
}
// ProductServedEcosystems derives native offers from a server product and its
// loader components, independent of installed packages.
func ProductServedEcosystems(
product catalog.VersionedRef,
components []catalog.VersionedRef,
) []ServedEcosystem {
return productServedEcosystems(product.Name.String(), product, components)
}
// selectionServedEcosystems derives offers from the selected installation
// identity only; installed packages never contribute.
func selectionServedEcosystems(
selection *discovery.Selection,
) []ServedEcosystem {
if selection == nil || !selection.IsValid() || selection.Installation == nil {
return nil
}
identity := selection.Installation.Identity
return productServedEcosystems(
identity.Primary.Name.String(),
identity.Primary,
identity.Components,
)
}
func productServedEcosystems(
name string,
primary catalog.VersionedRef,
components []catalog.VersionedRef,
) []ServedEcosystem {
offers := make([]ServedEcosystem, 0, 3)
name = strings.ToLower(strings.TrimSpace(name))
switch name {
case "fabric":
if primary.Ecosystem == catalog.EcosystemFabric {
offers = appendServedEcosystem(
offers,
catalog.EcosystemFabric,
CompatFull,
)
}
case "forge":
if primary.Ecosystem == catalog.EcosystemForge {
offers = appendServedEcosystem(
offers,
catalog.EcosystemForge,
CompatFull,
)
}
case "neoforge":
if primary.Ecosystem == catalog.EcosystemNeoForge {
offers = appendServedEcosystem(
offers,
catalog.EcosystemNeoForge,
CompatFull,
)
}
case "craftbukkit", "bukkit", "spigot":
offers = appendServedEcosystem(
offers,
catalog.EcosystemBukkit,
CompatFull,
)
case "arclight", "arclight-forge", "arclight-neoforge", "arclight-fabric":
switch name {
case "arclight-forge":
offers = appendServedEcosystem(offers, catalog.EcosystemForge, CompatFull)
case "arclight-neoforge":
offers = appendServedEcosystem(offers, catalog.EcosystemNeoForge, CompatFull)
case "arclight-fabric":
offers = appendServedEcosystem(offers, catalog.EcosystemFabric, CompatFull)
default:
for _, ecosystem := range selectedLoaderEcosystems(components) {
offers = appendServedEcosystem(offers, ecosystem, CompatFull)
}
}
if len(offers) > 0 {
offers = appendServedEcosystem(
offers,
catalog.EcosystemBukkit,
CompatFull,
)
}
case "catserver":
offers = appendServedEcosystem(
offers,
catalog.EcosystemForge,
CompatFull,
)
offers = appendServedEcosystem(
offers,
catalog.EcosystemBukkit,
CompatFull,
)
case "youer":
offers = appendServedEcosystem(
offers,
catalog.EcosystemNeoForge,
CompatFull,
)
offers = appendServedEcosystem(
offers,
catalog.EcosystemPaper,
CompatFull,
)
offers = appendServedEcosystem(
offers,
catalog.EcosystemBukkit,
CompatFull,
)
case "spongevanilla", "spongeforge", "spongeneo":
offers = appendServedEcosystem(
offers,
catalog.EcosystemSponge,
CompatFull,
)
for _, ecosystem := range selectedLoaderEcosystems(
components,
) {
if ecosystem == catalog.EcosystemFabric {
continue
}
offers = appendServedEcosystem(
offers,
ecosystem,
CompatFull,
)
}
case "velocity":
offers = appendServedEcosystem(
offers,
catalog.EcosystemVelocity,
CompatFull,
)
case "bungeecord", "waterfall":
offers = appendServedEcosystem(
offers,
catalog.EcosystemBungeeCord,
CompatFull,
)
default:
if discovery.IsPaperLineage(name) {
offers = appendServedEcosystem(
offers,
catalog.EcosystemPaper,
CompatFull,
)
offers = appendServedEcosystem(
offers,
catalog.EcosystemBukkit,
CompatFull,
)
}
}
return offers
}
// Supports reports whether pkg runs on the observed runtime. It returns the
// ecosystem that serves pkg and the support level. ok is false when no
// ecosystem serves pkg. A full offer wins over a degraded offer.
func (ws *Workspace) Supports(
pkg catalog.VersionedRef,
) (offered catalog.Ecosystem, level Compatibility, ok bool) {
offers := ws.ServedEcosystems()
serving := -1
for i, offer := range offers {
if !offer.Ecosystem.Satisfy(pkg.Ecosystem) {
continue
}
if serving < 0 ||
offers[serving].Compatibility == CompatDegraded &&
offer.Compatibility == CompatFull {
serving = i
}
}
if serving < 0 {
return catalog.EcosystemUnspecified, "", false
}
best := offers[serving]
if best.Compatibility == CompatFull {
return best.Ecosystem, CompatFull, true
}
return best.Ecosystem, CompatDegraded,
supportsDegraded(best.Ecosystem, pkg)
}
// supportsDegraded validates one package against a degraded offer. The
// default rule accepts every package. Ecosystem-specific checks replace it
// over time. Example: Fabric content over the Sinytra bridge needs a lookup
// in the discovery compatibility report.
func supportsDegraded(eco catalog.Ecosystem, pkg catalog.VersionedRef) bool {
switch eco {
default:
return true
}
}
func appendServedEcosystem(
offers []ServedEcosystem,
ecosystem catalog.Ecosystem,
compatibility Compatibility,
) []ServedEcosystem {
if ecosystem == catalog.EcosystemUnspecified {
return offers
}
for i := range offers {
if offers[i].Ecosystem != ecosystem {
continue
}
if compatibility == CompatFull {
offers[i].Compatibility = compatibility
}
return offers
}
return append(offers, ServedEcosystem{
Compatibility: compatibility,
Ecosystem: ecosystem,
})
}
func selectedLoaderEcosystems(
components []catalog.VersionedRef,
) []catalog.Ecosystem {
var ecosystems []catalog.Ecosystem
for _, component := range components {
name := strings.ToLower(strings.TrimSpace(component.Name.String()))
switch {
case component.Ecosystem == catalog.EcosystemFabric &&
(name == "fabric-loader" || name == "fabricloader"):
ecosystems = appendUniqueEcosystem(
ecosystems,
catalog.EcosystemFabric,
)
case component.Ecosystem == catalog.EcosystemForge && name == "forge":
ecosystems = appendUniqueEcosystem(
ecosystems,
catalog.EcosystemForge,
)
case component.Ecosystem == catalog.EcosystemNeoForge && name == "neoforge":
ecosystems = appendUniqueEcosystem(
ecosystems,
catalog.EcosystemNeoForge,
)
}
}
return ecosystems
}
func appendUniqueEcosystem(
ecosystems []catalog.Ecosystem,
ecosystem catalog.Ecosystem,
) []catalog.Ecosystem {
if slices.Contains(ecosystems, ecosystem) {
return ecosystems
}
return append(ecosystems, ecosystem)
}
func hasDirectOffer(
offers []ServedEcosystem,
ecosystem catalog.Ecosystem,
) bool {
for _, offer := range offers {
if offer.Ecosystem == ecosystem &&
offer.Compatibility == CompatFull {
return true
}
}
return false
}
// isConnectorBridgePackage reports whether pkg is Sinytra Connector. The
// mod id varied over time:
//
// - "connectormod": FML modId of releases up to 1.20.1
// - "connector": FML modId since 1.21 (renamed per
// https://github.com/Sinytra/Connector/discussions/1293) and the
// Modrinth project slug
// - "sinytra-connector": the CurseForge project slug
func isConnectorBridgePackage(pkg DiscoveredPackage) bool {
if pkg.ID.Ecosystem != catalog.EcosystemForge &&
pkg.ID.Ecosystem != catalog.EcosystemNeoForge {
return false
}
switch strings.ToLower(strings.TrimSpace(pkg.ID.Name.String())) {
case "sinytra-connector", "connector", "connectormod":
return true
default:
return false
}
}
package workspace
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
versionpkg "github.com/mclucy/lucy/version"
"gopkg.in/yaml.v3"
)
const mcdrConfigFileName = "config.yml"
// detectWrappers reads the wrappers of a server directory and the
// recoverable warnings seen while reading them. These wrappers do not come
// from jars. An MCDR installation manages a server directory from outside
// the directory.
func detectWrappers(dir string) (WrapperInfo, []error) {
var wrappers WrapperInfo
warnings := detectMCDRWrapper(dir, &wrappers)
return wrappers, warnings
}
// detectMCDRWrapper detects an MCDR managed directory and returns the MCDR
// version-detection warnings. The marker is the config.yml file. It records
// the wrapper version. The version comes from the mcdreforged command on
// PATH. Relative plugin directories resolve against dir.
//
// MCDR changes the server directory, so later consumers will not depend on
// pwd. Warnings are diagnostics for CLI presentation; the wrapper detection
// itself only logs them.
func detectMCDRWrapper(dir string, wrappers *WrapperInfo) []error {
var warnings []error
configPath := filepath.Join(dir, mcdrConfigFileName)
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return nil
}
configFile, err := os.Open(configPath)
if err != nil {
warnErr := fmt.Errorf("cannot open %s: %w", configPath, err)
warnings = append(warnings, warnErr)
return warnings
}
defer configFile.Close()
config := &MCDRConfig{}
if err := yaml.NewDecoder(configFile).Decode(config); err != nil {
warnErr := fmt.Errorf("cannot decode %s: %w", configPath, err)
warnings = append(warnings, warnErr)
return warnings
}
for i, pluginDir := range config.PluginDirectories {
if !filepath.IsAbs(pluginDir) {
config.PluginDirectories[i] = filepath.Join(dir, pluginDir)
}
}
// A hung mcdreforged must not block workspace observation.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
version := versionpkg.Unknown
// MCDReforged is provisioned by install, so absence from PATH is normal.
if output, err := exec.CommandContext(ctx, "mcdreforged", "--version").Output(); err == nil {
if fields := strings.Fields(string(output)); len(fields) > 1 {
version = versionpkg.Version(fields[1])
} else {
warnErr := fmt.Errorf("cannot parse mcdr version output")
warnings = append(warnings, warnErr)
}
}
wrappers.MCDR = &MCDREnv{
Version: version,
Config: config,
}
return warnings
}
package workspace
import (
"cmp"
"sort"
"github.com/mclucy/lucy/catalog"
)
// PackageIndex is a map-backed index over discovered packages, keyed by
// the qualified identity StringQualified(). All exported methods return
// results in a stable order; raw map iteration order never reaches callers.
type PackageIndex struct {
pkgs map[string]DiscoveredPackage
}
func NewPackageIndex() *PackageIndex {
return &PackageIndex{
pkgs: make(map[string]DiscoveredPackage),
}
}
// Add inserts a package into the index. First-write wins, except that a
// non-empty Path replaces an existing entry whose Path is empty, so an
// entry without a local path can gain one when discovered.
func (idx *PackageIndex) Add(pkg DiscoveredPackage) {
key := pkg.ID.StringQualified()
existing, exists := idx.pkgs[key]
if exists {
if existing.Path != "" || pkg.Path == "" {
return
}
}
idx.pkgs[key] = pkg
}
// Merge bulk-adds a slice of packages into the index. Each package is subject
// to the same dedupe policy as Add.
func (idx *PackageIndex) Merge(pkgs []DiscoveredPackage) {
for _, pkg := range pkgs {
idx.Add(pkg)
}
}
// Packages returns all indexed packages sorted ascending by platform,
// name, and version string.
func (idx *PackageIndex) Packages() []DiscoveredPackage {
result := make([]DiscoveredPackage, 0, len(idx.pkgs))
for _, pkg := range idx.pkgs {
result = append(result, pkg)
}
sort.Slice(result, func(i, j int) bool {
pi, pj := result[i].ID, result[j].ID
if c := cmp.Compare(pi.Ecosystem.String(), pj.Ecosystem.String()); c != 0 {
return c < 0
}
if c := cmp.Compare(pi.Name.String(), pj.Name.String()); c != 0 {
return c < 0
}
return cmp.Compare(pi.Version.String(), pj.Version.String()) < 0
})
return result
}
// LookupByID performs an exact lookup by the qualified identity
// (StringQualified()), returning a zero package and false when absent.
func (idx *PackageIndex) LookupByID(id catalog.VersionedRef) (
DiscoveredPackage,
bool,
) {
pkg, ok := idx.pkgs[id.StringQualified()]
return pkg, ok
}
// LookupByEcosystemName returns all packages matching the given platform
// and name, sorted by version string ascending, or nil when none match.
func (idx *PackageIndex) LookupByEcosystemName(
platform catalog.Ecosystem,
name string,
) []DiscoveredPackage {
var matches []DiscoveredPackage
for _, pkg := range idx.pkgs {
if pkg.ID.Ecosystem == platform && pkg.ID.Name.String() == name {
matches = append(matches, pkg)
}
}
if len(matches) == 0 {
return nil
}
sort.Slice(matches, func(i, j int) bool {
return cmp.Compare(matches[i].ID.Version.String(), matches[j].ID.Version.String()) < 0
})
return matches
}
package workspace
import (
"fmt"
"os"
"path/filepath"
"slices"
"sync"
"github.com/mclucy/lucy/artifact"
"github.com/mclucy/lucy/catalog"
)
// discoverPackages inventories the packages under searchPaths and
// mcdrPluginDirs, when the MCDR wrapper exists. Deduplication and
// local-path enrichment follow the PackageIndex.Add policy.
//
// The scan is local-only. It returns the discovered packages together with
// the sorted, deduplicated paths of jar files whose descriptor could not be
// read. Identifying those packages is a separate step.
func discoverPackages(
searchPaths []string,
mcdrPluginDirs []string,
) ([]DiscoveredPackage, []string, []error) {
idx := NewPackageIndex()
var (
mu sync.Mutex
unidentified []string
warnings []error
)
addWarning := func(err error) {
mu.Lock()
warnings = append(warnings, err)
mu.Unlock()
}
for _, searchPath := range searchPaths {
jarFiles, err := findJar(searchPath)
if err != nil {
addWarning(fmt.Errorf("cannot read the mod directory: %w", err))
continue
}
var wg sync.WaitGroup
for _, jarPath := range jarFiles {
wg.Go(func() {
analyzed, err := artifact.ReadInfo(jarPath)
if err != nil || len(analyzed) == 0 {
// An unreadable descriptor leaves the package
// unidentified; the path is retained for a later
// identify step.
mu.Lock()
unidentified = append(unidentified, jarPath)
mu.Unlock()
return
}
pkgs := artifactInfoToDiscoveredPackage(analyzed)
mu.Lock()
idx.Merge(pkgs)
mu.Unlock()
})
}
wg.Wait()
}
for _, dir := range mcdrPluginDirs {
pluginFiles, err := findFileWithExt([]string{dir}, ".pyz", ".mcdr")
if err != nil {
addWarning(fmt.Errorf("cannot read the MCDR plugin directory: %w", err))
continue
}
for _, pluginFile := range pluginFiles {
analyzed, err := artifact.ReadInfo(pluginFile)
if err == nil && len(analyzed) > 0 {
pkgs := artifactInfoToDiscoveredPackage(analyzed)
idx.Merge(pkgs)
}
}
}
return idx.Packages(), sortUniquePaths(unidentified), warnings
}
// sortUniquePaths returns paths in ascending order without duplicates. A
// nil or single-element input is returned as is.
func sortUniquePaths(paths []string) []string {
if len(paths) < 2 {
return paths
}
slices.Sort(paths)
return slices.Compact(paths)
}
// findJar lists regular files with a .jar extension directly inside each
// given directory. It does not walk subdirectories. Servers keep their
// runtime jars at shallow locations with known names.
func findJar(dir ...string) ([]string, error) {
return findFileWithExt(dir, ".jar")
}
func findFileWithExt(dirs []string, ext ...string) ([]string, error) {
var files []string
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if slices.Contains(ext, filepath.Ext(entry.Name())) {
files = append(files, filepath.Join(dir, entry.Name()))
}
}
}
return files, nil
}
func artifactInfoToDiscoveredPackage(infos []artifact.PackageInfo) []DiscoveredPackage {
if len(infos) == 0 {
return nil
}
pkgs := make([]DiscoveredPackage, 0, len(infos))
for _, info := range infos {
pkg := DiscoveredPackage{
ID: catalog.VersionedRef{
Ref: info.Ref.Ref,
Ecosystem: info.Ref.Ecosystem,
Version: info.Ref.Version,
},
Path: info.FilePath,
}
if len(info.Dependencies) > 0 {
deps := make([]catalog.Dependency, 0, len(info.Dependencies))
for _, dep := range info.Dependencies {
deps = append(deps, catalog.Dependency{
ID: catalog.VersionedRef{
Ref: dep.Ref.Ref,
Ecosystem: dep.Ref.Ecosystem,
},
Constraint: dep.Constraint,
Mandatory: dep.Mandatory,
Origin: catalog.NormalizeDependencyOrigin(dep.Origin),
})
}
pkg.Dependencies = deps
}
pkgs = append(pkgs, pkg)
}
return pkgs
}
package workspace
import (
"path/filepath"
"slices"
"github.com/mclucy/lucy/catalog"
"github.com/mclucy/lucy/internal/fileschema"
"github.com/mclucy/lucy/workspace/discovery"
"gopkg.in/ini.v1"
)
// PackageDirs lists the directories that hold content packages for this
// observation. Loader runtimes use mods/. Bukkit family servers use
// plugins/. The list is empty when no runtime was identified.
func (ws *Workspace) PackageDirs() []string {
if ws == nil {
return nil
}
return packageSearchPaths(ws.Runtime(), ws.ServerDir)
}
// packageSearchPaths maps full-support ecosystem offers onto conventional
// directory names. Duplicate names appear once, in offer order.
func packageSearchPaths(
selection *discovery.Selection,
root string,
) []string {
if selection == nil || !selection.IsValid() {
return nil
}
var paths []string
for _, offer := range selectionServedEcosystems(selection) {
if offer.Compatibility != CompatFull {
continue
}
var path string
switch offer.Ecosystem {
case catalog.EcosystemFabric, catalog.EcosystemForge, catalog.EcosystemNeoForge:
path = filepath.Join(root, "mods")
case catalog.EcosystemBukkit, catalog.EcosystemPaper:
path = filepath.Join(root, "plugins")
default:
continue
}
duplicate := slices.Contains(paths, path)
if !duplicate {
paths = append(paths, path)
}
}
return paths
}
// SaveDir returns the world save directory from server.properties. The path
// is ServerDir joined with the level-name property. SaveDir returns an empty
// string when no usable properties file exists. A missing level-name
// degrades to ServerDir. This matches the runtime default of storing the
// world beside the server.
func (ws *Workspace) SaveDir() string {
if ws == nil {
return ""
}
properties := parseServerProperties(ws.ServerDir)
if properties == nil {
return ""
}
return filepath.Join(ws.ServerDir, properties["level-name"])
}
// parseServerProperties reads root/server.properties into a flat map of key
// and value pairs. It returns nil after any read error or parse error. A
// missing file is normal for new server directories.
func parseServerProperties(
root string,
) fileschema.FileMinecraftServerProperties {
file, err := ini.Load(filepath.Join(root, "server.properties"))
if err != nil {
return nil
}
properties := make(map[string]string, len(file.Sections())*8)
for _, section := range file.Sections() {
for _, key := range section.Keys() {
properties[key.Name()] = key.String()
}
}
return properties
}
//go:build unix || darwin || linux
package workspace
import (
"bytes"
"errors"
"os"
"os/exec"
"strconv"
"strings"
"syscall"
)
// checkSessionLock reports whether another process holds session.lock. A
// result of false covers every failure mode. An unreadable or unlocked file
// says nothing about a running server.
//
// lsof runs first. Plain flock probes were unstable on some Linux kernels.
// lsof gives a definite answer when a java process holds the file open.
func checkSessionLock(lockPath string) bool {
if sessionLockHeldByLsof(lockPath) {
return true
}
file, err := os.OpenFile(lockPath, os.O_RDWR|os.O_APPEND, 0o666)
if err != nil {
return false
}
defer file.Close()
err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err != nil && errors.Is(err, syscall.EWOULDBLOCK) {
return true
} else if err != nil {
return false
}
_ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
return false
}
// sessionLockHeldByLsof asks the lsof tool whether any process holds
// filePath open.
func sessionLockHeldByLsof(filePath string) bool {
cmd := exec.Command("lsof", filePath)
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return false
}
lines := strings.Split(out.String(), "\n")
outputBegin := 0
for i, line := range lines {
if strings.Contains(line, "COMMAND") {
outputBegin = i + 1
break
}
}
for _, line := range lines[outputBegin:] {
fields := strings.Fields(line)
// Only the server JVM counts as server activity. Other holders, such
// as editors and shells, do not count.
if len(fields) >= 2 &&
fields[0] == "java" &&
pidLike(fields[1]) {
return true
}
}
return false
}
func pidLike(field string) bool {
_, err := strconv.Atoi(field)
return err == nil
}