пися попа говно член пенис параша залуууууууупа сиси говно
This commit is contained in:
parent
2c7c0f877b
commit
05eabf12a7
14 changed files with 1883 additions and 138 deletions
|
|
@ -56,6 +56,8 @@ install:
|
|||
deps:
|
||||
- glibc
|
||||
- readline
|
||||
- "zlib>=1.2.13"
|
||||
- "lua5.1 | luajit"
|
||||
```
|
||||
|
||||
Debian upstream source mode:
|
||||
|
|
@ -71,10 +73,14 @@ source:
|
|||
```
|
||||
|
||||
Notes:
|
||||
- `deps` are simple package names (no version solver yet).
|
||||
- `deps` support full constraints: `name`, `name>=1.2`, `name (<= 2.0)`, and alternatives via `|`.
|
||||
- Installer resolves dependencies with version checks and transaction ordering (topological sort), including mixed installed + transaction packages.
|
||||
- `{{pkgdir}}` (and `${pkgdir}`) points to staging DESTDIR.
|
||||
- If `source.debian_dsc` (or `.dsc` URL) is used, zsvo downloads only `*.orig*.tar.*` archives from that source package and ignores Debian patch archives (`debian.tar.*` / `diff.gz`).
|
||||
- `zsvo install <name>` auto-resolves Debian source over HTTP from Debian `Sources` indexes, builds package in `--work-dir` and installs it.
|
||||
- Auto-build in `install <name>` is quiet by default and shows a colored live progress bar (spinner + percent + elapsed time) instead of raw compiler output; full command output tail is shown on failure.
|
||||
- If build fails due to missing tools (`cmake`, `meson`, `pkg-config`, `lua` etc.), `zsvo install` auto-detects them, builds missing dependencies from Debian source with zsvo itself, installs them into `--work-dir/bootstrap-root`, and retries the build (`--auto-build-deps=false` to disable).
|
||||
- Auto-build can be tuned with env flags: `ZSVO_AUTOGEN_ARGS`, `ZSVO_CONFIGURE_FLAGS`, `ZSVO_CMAKE_FLAGS`, `ZSVO_CMAKE_BUILD_FLAGS`, `ZSVO_MESON_SETUP_ARGS`, `ZSVO_MESON_COMPILE_ARGS`, `ZSVO_MESON_INSTALL_ARGS`, `ZSVO_MAKE_FLAGS`, `ZSVO_MAKE_INSTALL_FLAGS`.
|
||||
- Package metadata is stored in `.zsvo.yml` (not `.PKGINFO`).
|
||||
|
||||
## Architecture
|
||||
|
|
|
|||
607
cmd/install.go
607
cmd/install.go
|
|
@ -3,8 +3,12 @@ package cmd
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"zsvo/pkg/builder"
|
||||
|
|
@ -28,13 +32,14 @@ var InstallCmd = &cobra.Command{
|
|||
workDir = "/tmp/pkg-work"
|
||||
}
|
||||
autoSource, _ := cmd.Flags().GetBool("auto-source")
|
||||
autoBuildDeps, _ := cmd.Flags().GetBool("auto-build-deps")
|
||||
|
||||
installTargets := make([]string, 0, len(args))
|
||||
var resolver *debian.Resolver
|
||||
var b *builder.Builder
|
||||
i := installer.NewInstaller(rootDir)
|
||||
|
||||
var session *autoBuildSession
|
||||
if autoSource {
|
||||
resolver = debian.NewResolver()
|
||||
b = builder.NewBuilder(workDir)
|
||||
session = newAutoBuildSession(workDir, autoBuildDeps)
|
||||
}
|
||||
|
||||
for _, target := range args {
|
||||
|
|
@ -55,25 +60,13 @@ var InstallCmd = &cobra.Command{
|
|||
)
|
||||
}
|
||||
|
||||
fmt.Printf("Resolving Debian source for %s...\n", target)
|
||||
srcInfo, err := resolver.ResolveSource(target)
|
||||
builtPackage, err := session.buildPackage(target, false, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rcp := autoRecipeFromDebian(srcInfo)
|
||||
fmt.Printf("Building %s from %s...\n", rcp.GetPackageName(), srcInfo.DSCURL)
|
||||
if err := b.Build(rcp); err != nil {
|
||||
return fmt.Errorf("failed to auto-build %s: %w", target, err)
|
||||
}
|
||||
|
||||
builtPackage := filepath.Join(rcp.GetPackageDir(workDir), rcp.GetPackageFileName())
|
||||
fmt.Printf("Built package: %s\n", builtPackage)
|
||||
installTargets = append(installTargets, builtPackage)
|
||||
}
|
||||
|
||||
i := installer.NewInstaller(rootDir)
|
||||
|
||||
if len(installTargets) == 1 {
|
||||
fmt.Printf("Installing package from %s...\n", installTargets[0])
|
||||
} else {
|
||||
|
|
@ -92,6 +85,7 @@ func init() {
|
|||
InstallCmd.Flags().StringP("root", "r", "/", "Root directory for installation")
|
||||
InstallCmd.Flags().StringP("work-dir", "w", "/tmp/pkg-work", "Working directory for source builds")
|
||||
InstallCmd.Flags().Bool("auto-source", true, "Auto-build package names from Debian source")
|
||||
InstallCmd.Flags().Bool("auto-build-deps", true, "Auto-build missing source build dependencies through zsvo")
|
||||
}
|
||||
|
||||
func isInstallFileTarget(target string) (bool, error) {
|
||||
|
|
@ -130,6 +124,375 @@ func looksLikeFilePath(target string) bool {
|
|||
strings.HasSuffix(target, ".zov")
|
||||
}
|
||||
|
||||
const maxAutoBuildDepth = 16
|
||||
|
||||
type autoBuildSession struct {
|
||||
workDir string
|
||||
toolRoot string
|
||||
autoBuildDeps bool
|
||||
resolver *debian.Resolver
|
||||
builder *builder.Builder
|
||||
toolInstaller *installer.Installer
|
||||
builtPackages map[string]string
|
||||
toolDepsReady map[string]struct{}
|
||||
buildingPackages map[string]struct{}
|
||||
}
|
||||
|
||||
func newAutoBuildSession(workDir string, autoBuildDeps bool) *autoBuildSession {
|
||||
b := builder.NewBuilder(workDir)
|
||||
b.SetQuiet(true)
|
||||
|
||||
s := &autoBuildSession{
|
||||
workDir: workDir,
|
||||
toolRoot: filepath.Join(workDir, "bootstrap-root"),
|
||||
autoBuildDeps: autoBuildDeps,
|
||||
resolver: debian.NewResolver(),
|
||||
builder: b,
|
||||
toolInstaller: installer.NewInstaller(filepath.Join(workDir, "bootstrap-root")),
|
||||
builtPackages: make(map[string]string),
|
||||
toolDepsReady: make(map[string]struct{}),
|
||||
buildingPackages: make(map[string]struct{}),
|
||||
}
|
||||
s.refreshBuildEnv()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *autoBuildSession) refreshBuildEnv() {
|
||||
basePath := splitPathList(os.Getenv("PATH"))
|
||||
binPrefixes := []string{
|
||||
filepath.Join(s.toolRoot, "usr", "bin"),
|
||||
filepath.Join(s.toolRoot, "bin"),
|
||||
filepath.Join(s.toolRoot, "usr", "sbin"),
|
||||
filepath.Join(s.toolRoot, "sbin"),
|
||||
}
|
||||
mergedPath := joinPathListUnique(append(binPrefixes, basePath...))
|
||||
s.builder.SetEnvOverride("PATH", mergedPath)
|
||||
|
||||
pkgConfigPath := splitPathList(os.Getenv("PKG_CONFIG_PATH"))
|
||||
pkgConfigPrefixes := []string{
|
||||
filepath.Join(s.toolRoot, "usr", "lib", "pkgconfig"),
|
||||
filepath.Join(s.toolRoot, "usr", "lib64", "pkgconfig"),
|
||||
filepath.Join(s.toolRoot, "usr", "share", "pkgconfig"),
|
||||
filepath.Join(s.toolRoot, "lib", "pkgconfig"),
|
||||
filepath.Join(s.toolRoot, "lib64", "pkgconfig"),
|
||||
}
|
||||
s.builder.SetEnvOverride("PKG_CONFIG_PATH", joinPathListUnique(append(pkgConfigPrefixes, pkgConfigPath...)))
|
||||
cmakePrefixes := []string{
|
||||
filepath.Join(s.toolRoot, "usr"),
|
||||
filepath.Join(s.toolRoot),
|
||||
}
|
||||
cmakePrefixes = append(cmakePrefixes, splitPathList(os.Getenv("CMAKE_PREFIX_PATH"))...)
|
||||
s.builder.SetEnvOverride("CMAKE_PREFIX_PATH", joinPathListUnique(cmakePrefixes))
|
||||
}
|
||||
|
||||
func (s *autoBuildSession) buildPackage(requestName string, asBuildDep bool, stack []string) (string, error) {
|
||||
requestName = normalizePackageName(requestName)
|
||||
if requestName == "" {
|
||||
return "", fmt.Errorf("invalid package name")
|
||||
}
|
||||
|
||||
if len(stack) >= maxAutoBuildDepth {
|
||||
return "", fmt.Errorf("dependency chain is too deep while building %s: %s", requestName, strings.Join(append(stack, requestName), " -> "))
|
||||
}
|
||||
if _, exists := s.buildingPackages[requestName]; exists {
|
||||
return "", fmt.Errorf("dependency cycle detected: %s", strings.Join(append(stack, requestName), " -> "))
|
||||
}
|
||||
|
||||
if builtPath, ok := s.builtPackages[requestName]; ok {
|
||||
if asBuildDep {
|
||||
if err := s.installBuildDependency(requestName, builtPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return builtPath, nil
|
||||
}
|
||||
|
||||
s.buildingPackages[requestName] = struct{}{}
|
||||
defer delete(s.buildingPackages, requestName)
|
||||
|
||||
fmt.Printf("Resolving Debian source for %s...\n", requestName)
|
||||
srcInfo, err := s.resolver.ResolveSource(requestName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve source for %s: %w", requestName, err)
|
||||
}
|
||||
|
||||
rcp := autoRecipeFromDebian(srcInfo)
|
||||
normalizedRecipeName := normalizePackageName(rcp.Name)
|
||||
if normalizedRecipeName != "" && normalizedRecipeName != requestName {
|
||||
// Alias the resolved source package name to the requested name.
|
||||
if _, exists := s.buildingPackages[normalizedRecipeName]; exists {
|
||||
return "", fmt.Errorf("dependency cycle detected: %s", strings.Join(append(stack, requestName, normalizedRecipeName), " -> "))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Building %s from %s...\n", rcp.GetPackageName(), srcInfo.DSCURL)
|
||||
var buildErr error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
bar := newProgressUI(rcp.Name)
|
||||
s.builder.SetProgressCallback(func(p builder.BuildProgress) {
|
||||
bar.update(p.Step, p.Total, fmt.Sprintf("%s: %s", rcp.Name, p.Message))
|
||||
})
|
||||
|
||||
buildErr = s.builder.Build(rcp)
|
||||
s.builder.SetProgressCallback(nil)
|
||||
if buildErr == nil {
|
||||
bar.finish(true, fmt.Sprintf("%s: complete", rcp.Name))
|
||||
break
|
||||
}
|
||||
bar.finish(false, fmt.Sprintf("%s: failed", rcp.Name))
|
||||
|
||||
if !s.autoBuildDeps || attempt == 1 {
|
||||
hint := buildFailureHint(requestName, buildErr)
|
||||
if hint != "" {
|
||||
return "", fmt.Errorf("failed to auto-build %s: %w\n%s", requestName, buildErr, hint)
|
||||
}
|
||||
return "", fmt.Errorf("failed to auto-build %s: %w", requestName, buildErr)
|
||||
}
|
||||
|
||||
missingDeps := inferMissingBuildDeps(buildErr)
|
||||
if len(missingDeps) == 0 {
|
||||
hint := buildFailureHint(requestName, buildErr)
|
||||
if hint != "" {
|
||||
return "", fmt.Errorf("failed to auto-build %s: %w\n%s", requestName, buildErr, hint)
|
||||
}
|
||||
return "", fmt.Errorf("failed to auto-build %s: %w", requestName, buildErr)
|
||||
}
|
||||
|
||||
fmt.Printf("Detected missing build dependencies for %s: %s\n", requestName, strings.Join(missingDeps, ", "))
|
||||
for _, dep := range missingDeps {
|
||||
if dep == requestName || dep == normalizedRecipeName {
|
||||
continue
|
||||
}
|
||||
if _, ready := s.toolDepsReady[dep]; ready {
|
||||
continue
|
||||
}
|
||||
if err := s.ensureBuildDependency(dep, append(stack, requestName)); err != nil {
|
||||
return "", fmt.Errorf("failed to satisfy build dependency %s for %s: %w", dep, requestName, err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Retrying build for %s after auto-installing dependencies...\n", requestName)
|
||||
}
|
||||
|
||||
if buildErr != nil {
|
||||
return "", fmt.Errorf("failed to auto-build %s: %w", requestName, buildErr)
|
||||
}
|
||||
|
||||
builtPackage := filepath.Join(rcp.GetPackageDir(s.workDir), rcp.GetPackageFileName())
|
||||
fmt.Printf("Built package: %s\n", builtPackage)
|
||||
|
||||
s.builtPackages[requestName] = builtPackage
|
||||
if normalizedRecipeName != "" {
|
||||
s.builtPackages[normalizedRecipeName] = builtPackage
|
||||
}
|
||||
|
||||
if asBuildDep {
|
||||
if err := s.installBuildDependency(requestName, builtPackage); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return builtPackage, nil
|
||||
}
|
||||
|
||||
func (s *autoBuildSession) ensureBuildDependency(dep string, stack []string) error {
|
||||
dep = normalizePackageName(dep)
|
||||
if dep == "" {
|
||||
return fmt.Errorf("invalid build dependency name")
|
||||
}
|
||||
|
||||
if _, ready := s.toolDepsReady[dep]; ready {
|
||||
return nil
|
||||
}
|
||||
if toolAlreadyAvailable(dep) {
|
||||
s.toolDepsReady[dep] = struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
packagePath, err := s.buildPackage(dep, true, stack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.installBuildDependency(dep, packagePath)
|
||||
}
|
||||
|
||||
func (s *autoBuildSession) installBuildDependency(dep, packagePath string) error {
|
||||
dep = normalizePackageName(dep)
|
||||
if dep == "" {
|
||||
return fmt.Errorf("invalid build dependency name")
|
||||
}
|
||||
if _, ready := s.toolDepsReady[dep]; ready {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Installing build dependency %s into %s...\n", dep, s.toolRoot)
|
||||
if err := s.toolInstaller.Install(packagePath); err != nil {
|
||||
return fmt.Errorf("failed to install build dependency %s: %w", dep, err)
|
||||
}
|
||||
s.toolDepsReady[dep] = struct{}{}
|
||||
s.refreshBuildEnv()
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitPathList(value string) []string {
|
||||
parts := strings.Split(value, string(os.PathListSeparator))
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func joinPathListUnique(parts []string) string {
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[part]; exists {
|
||||
continue
|
||||
}
|
||||
seen[part] = struct{}{}
|
||||
out = append(out, part)
|
||||
}
|
||||
return strings.Join(out, string(os.PathListSeparator))
|
||||
}
|
||||
|
||||
var simplePkgNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]*$`)
|
||||
var missingCommandPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?m)(?:^|[\s:])(?:/bin/)?sh:\s*(?:\d+:\s*)?([a-zA-Z0-9+_.-]+):\s*(?:command not found|not found)\b`),
|
||||
regexp.MustCompile(`(?m)\b([a-zA-Z0-9+_.-]+):\s*command not found\b`),
|
||||
regexp.MustCompile(`(?m)\b([a-zA-Z0-9+_.-]+):\s*not found\b`),
|
||||
}
|
||||
|
||||
func inferMissingBuildDeps(err error) []string {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
text := err.Error()
|
||||
lowerText := strings.ToLower(text)
|
||||
found := make(map[string]struct{})
|
||||
|
||||
for _, pattern := range missingCommandPatterns {
|
||||
matches := pattern.FindAllStringSubmatch(text, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
pkg := mapToolToSourcePackage(match[1])
|
||||
if pkg == "" {
|
||||
continue
|
||||
}
|
||||
found[pkg] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(lowerText, "failed to find a lua 5.1-compatible interpreter") {
|
||||
found["lua5.1"] = struct{}{}
|
||||
}
|
||||
if strings.Contains(lowerText, "pkg-config") &&
|
||||
(strings.Contains(lowerText, "not found") || strings.Contains(lowerText, "could not find")) {
|
||||
found["pkgconf"] = struct{}{}
|
||||
}
|
||||
if strings.Contains(lowerText, "no acceptable c compiler found in $path") ||
|
||||
strings.Contains(lowerText, "c compiler cannot create executables") {
|
||||
found["gcc"] = struct{}{}
|
||||
}
|
||||
|
||||
if len(found) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
deps := make([]string, 0, len(found))
|
||||
for dep := range found {
|
||||
deps = append(deps, dep)
|
||||
}
|
||||
sort.Strings(deps)
|
||||
return deps
|
||||
}
|
||||
|
||||
func mapToolToSourcePackage(tool string) string {
|
||||
tool = normalizePackageName(tool)
|
||||
if tool == "" || allDigits(tool) {
|
||||
return ""
|
||||
}
|
||||
switch tool {
|
||||
case "sh", "bash", "dash", "zsh":
|
||||
return ""
|
||||
case "pkg-config":
|
||||
return "pkgconf"
|
||||
case "ninja":
|
||||
return "ninja-build"
|
||||
case "python":
|
||||
return "python3"
|
||||
case "lua":
|
||||
return "lua5.1"
|
||||
case "luajit":
|
||||
return "luajit"
|
||||
case "cc", "c++", "g++", "gcc":
|
||||
return "gcc"
|
||||
case "ld":
|
||||
return "binutils"
|
||||
case "xzcat":
|
||||
return "xz-utils"
|
||||
}
|
||||
|
||||
if !simplePkgNamePattern.MatchString(tool) {
|
||||
return ""
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
func allDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePackageName(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
var commandHintsByDep = map[string][]string{
|
||||
"pkgconf": {"pkg-config"},
|
||||
"ninja-build": {"ninja"},
|
||||
"python3": {"python3", "python"},
|
||||
"lua5.1": {"lua", "lua5.1"},
|
||||
}
|
||||
|
||||
func toolAlreadyAvailable(dep string) bool {
|
||||
dep = normalizePackageName(dep)
|
||||
if dep == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
commands := commandHintsByDep[dep]
|
||||
if len(commands) == 0 {
|
||||
commands = []string{dep}
|
||||
}
|
||||
|
||||
for _, name := range commands {
|
||||
if _, err := exec.LookPath(name); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe {
|
||||
name := src.SourcePackage
|
||||
if name == "" {
|
||||
|
|
@ -142,8 +505,8 @@ func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe {
|
|||
|
||||
installCmd := fmt.Sprintf(
|
||||
"if [ -f build/cmake_install.cmake ]; then DESTDIR={{pkgdir}} cmake --install build; "+
|
||||
"elif [ -f build/meson-private/coredata.dat ]; then DESTDIR={{pkgdir}} meson install -C build; "+
|
||||
"elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make DESTDIR={{pkgdir}} PREFIX=/usr install; "+
|
||||
"elif [ -f build/meson-private/coredata.dat ]; then DESTDIR={{pkgdir}} meson install -C build ${ZSVO_MESON_INSTALL_ARGS}; "+
|
||||
"elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make DESTDIR={{pkgdir}} PREFIX=/usr ${ZSVO_MAKE_INSTALL_FLAGS} install; "+
|
||||
"elif [ -f %s ]; then install -Dm755 %s {{pkgdir}}/usr/bin/%s; fi",
|
||||
name,
|
||||
name,
|
||||
|
|
@ -159,11 +522,209 @@ func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe {
|
|||
Sha256: src.DSCSHA256,
|
||||
},
|
||||
Build: []string{
|
||||
"[ -f configure ] && ./configure --prefix=/usr || true",
|
||||
"if [ -f CMakeLists.txt ]; then cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr && cmake --build build -j${jobs}; " +
|
||||
"elif [ -f meson.build ]; then meson setup build --prefix=/usr && meson compile -C build -j${jobs}; " +
|
||||
"elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make -j${jobs}; fi",
|
||||
"if [ ! -f configure ] && [ -f autogen.sh ]; then sh ./autogen.sh --no-check ${ZSVO_AUTOGEN_ARGS}; fi; if [ -f configure ]; then ./configure --prefix=/usr ${ZSVO_CONFIGURE_FLAGS}; fi",
|
||||
"if [ -f CMakeLists.txt ]; then cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr ${ZSVO_CMAKE_FLAGS} && cmake --build build -j${jobs} -- ${ZSVO_CMAKE_BUILD_FLAGS}; " +
|
||||
"elif [ -f meson.build ]; then meson setup build --prefix=/usr ${ZSVO_MESON_SETUP_ARGS} && meson compile -C build -j${jobs} ${ZSVO_MESON_COMPILE_ARGS}; " +
|
||||
"elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make -j${jobs} ${ZSVO_MAKE_FLAGS}; fi",
|
||||
},
|
||||
Install: []string{installCmd},
|
||||
}
|
||||
}
|
||||
|
||||
type progressUI struct {
|
||||
total int
|
||||
pkgName string
|
||||
startedAt time.Time
|
||||
enabled bool
|
||||
frameIdx int
|
||||
lastLineLen int
|
||||
}
|
||||
|
||||
func newProgressUI(pkgName string) *progressUI {
|
||||
return &progressUI{
|
||||
pkgName: pkgName,
|
||||
startedAt: time.Now(),
|
||||
enabled: supportsANSIAndTTY(),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *progressUI) update(step, total int, message string) {
|
||||
if total <= 0 {
|
||||
total = 1
|
||||
}
|
||||
if step < 0 {
|
||||
step = 0
|
||||
}
|
||||
if step > total {
|
||||
step = total
|
||||
}
|
||||
p.total = total
|
||||
|
||||
if !p.enabled {
|
||||
fmt.Printf("[%d/%d] %s\n", step, total, message)
|
||||
return
|
||||
}
|
||||
|
||||
const width = 32
|
||||
filled := step * width / total
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
|
||||
percent := step * 100 / total
|
||||
spinner := progressFrames[p.frameIdx%len(progressFrames)]
|
||||
p.frameIdx++
|
||||
|
||||
bar := renderColoredBar(width, filled)
|
||||
elapsed := formatElapsed(time.Since(p.startedAt))
|
||||
|
||||
line := fmt.Sprintf(
|
||||
"\r%s %s %s %3d%% %s %s",
|
||||
colorize("36;1", spinner),
|
||||
colorize("1", p.pkgName),
|
||||
bar,
|
||||
percent,
|
||||
colorize("2", "| "+truncateText(message, 48)),
|
||||
colorize("2", elapsed),
|
||||
)
|
||||
|
||||
if pad := p.lastLineLen - visibleLen(line); pad > 0 {
|
||||
line += strings.Repeat(" ", pad)
|
||||
}
|
||||
p.lastLineLen = visibleLen(line)
|
||||
fmt.Print(line)
|
||||
}
|
||||
|
||||
func (p *progressUI) finish(ok bool, message string) {
|
||||
total := p.total
|
||||
if total <= 0 {
|
||||
total = 1
|
||||
}
|
||||
p.update(total, total, message)
|
||||
|
||||
if p.enabled {
|
||||
status := colorize("31;1", "FAIL")
|
||||
if ok {
|
||||
status = colorize("32;1", "DONE")
|
||||
}
|
||||
fmt.Printf(" %s %s\n", status, colorize("2", "("+formatElapsed(time.Since(p.startedAt))+")"))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
var progressFrames = []string{"|", "/", "-", "\\"}
|
||||
|
||||
func renderColoredBar(width, filled int) string {
|
||||
if width <= 0 {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
|
||||
done := strings.Repeat("=", filled)
|
||||
todo := strings.Repeat(".", width-filled)
|
||||
return "[" + colorize("32", done) + colorize("2", todo) + "]"
|
||||
}
|
||||
|
||||
func supportsANSIAndTTY() bool {
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return false
|
||||
}
|
||||
term := strings.TrimSpace(strings.ToLower(os.Getenv("TERM")))
|
||||
if term == "" || term == "dumb" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return (info.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
func colorize(code, text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return "\x1b[" + code + "m" + text + "\x1b[0m"
|
||||
}
|
||||
|
||||
func formatElapsed(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
totalSeconds := int(d.Seconds())
|
||||
minutes := totalSeconds / 60
|
||||
seconds := totalSeconds % 60
|
||||
return fmt.Sprintf("%02d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
func truncateText(s string, max int) string {
|
||||
if max <= 3 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
|
||||
func visibleLen(s string) int {
|
||||
// This is enough here because we only inject ANSI codes ourselves.
|
||||
n := 0
|
||||
inEsc := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
if inEsc {
|
||||
if ch == 'm' {
|
||||
inEsc = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == 0x1b {
|
||||
inEsc = true
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func buildFailureHint(pkgName string, err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
text := strings.ToLower(err.Error())
|
||||
pkgName = strings.TrimSpace(strings.ToLower(pkgName))
|
||||
hints := make([]string, 0, 4)
|
||||
|
||||
missingDeps := inferMissingBuildDeps(err)
|
||||
if len(missingDeps) > 0 {
|
||||
hints = append(
|
||||
hints,
|
||||
fmt.Sprintf("Hint: missing build dependencies detected: %s.", strings.Join(missingDeps, ", ")),
|
||||
"Hint: добавь рецепты для этих пакетов (или алиасы к Debian source), затем повтори `zsvo install`.",
|
||||
)
|
||||
}
|
||||
if strings.Contains(text, "on systems using dpkg and apt, try: \"apt-get install package\"") {
|
||||
hints = append(hints,
|
||||
"Hint: upstream configure-script ожидает системные build-зависимости; в zsvo это нужно решать рецептами/автосборкой зависимостей.",
|
||||
)
|
||||
}
|
||||
|
||||
if pkgName == "neovim" && strings.Contains(text, "lua") {
|
||||
hints = append(hints,
|
||||
"Hint (neovim): после установки Lua можно зафиксировать интерпретатор: `export ZSVO_CMAKE_FLAGS=\"-DLUA_PRG=$(which lua) -DLUA_GEN_PRG=$(which lua)\"`.",
|
||||
)
|
||||
}
|
||||
|
||||
if len(hints) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.Join(hints, "\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"zsvo/pkg/debian"
|
||||
|
|
@ -68,4 +71,59 @@ func TestAutoRecipeFromDebian(t *testing.T) {
|
|||
if len(r.Build) == 0 || len(r.Install) == 0 {
|
||||
t.Fatalf("expected auto recipe build/install commands")
|
||||
}
|
||||
if !strings.Contains(r.Build[0], "autogen.sh --no-check") {
|
||||
t.Fatalf("expected auto recipe to support autogen fallback, got: %s", r.Build[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFailureHintForLua(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := buildFailureHint("neovim", errors.New("Failed to find a Lua 5.1-compatible interpreter"))
|
||||
if !strings.Contains(strings.ToLower(msg), "lua") {
|
||||
t.Fatalf("expected lua hint, got: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferMissingBuildDeps_FromCommandNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := errors.New("sh: cmake: command not found\n/bin/sh: 1: pkg-config: not found")
|
||||
got := inferMissingBuildDeps(err)
|
||||
want := []string{"cmake", "pkgconf"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("inferMissingBuildDeps() mismatch:\nwant: %#v\ngot: %#v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferMissingBuildDeps_FromLuaError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := errors.New("CMake Error: Failed to find a Lua 5.1-compatible interpreter")
|
||||
got := inferMissingBuildDeps(err)
|
||||
want := []string{"lua5.1"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("inferMissingBuildDeps() mismatch:\nwant: %#v\ngot: %#v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToolToSourcePackage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"pkg-config": "pkgconf",
|
||||
"ninja": "ninja-build",
|
||||
"python": "python3",
|
||||
"lua": "lua5.1",
|
||||
"cmake": "cmake",
|
||||
"bash": "",
|
||||
"1": "",
|
||||
"": "",
|
||||
}
|
||||
|
||||
for input, want := range cases {
|
||||
if got := mapToolToSourcePackage(input); got != want {
|
||||
t.Fatalf("mapToolToSourcePackage(%q): want %q, got %q", input, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
main.go
6
main.go
|
|
@ -9,8 +9,10 @@ import (
|
|||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "zsvo",
|
||||
Short: "A simple source-based package manager",
|
||||
Use: "zsvo",
|
||||
Short: "A simple source-based package manager",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
Long: `A minimal package manager for custom Linux distributions based on LFS
|
||||
|
||||
Available commands:
|
||||
|
|
|
|||
|
|
@ -15,7 +15,17 @@ import (
|
|||
|
||||
// Builder handles building packages from recipes
|
||||
type Builder struct {
|
||||
workDir string
|
||||
workDir string
|
||||
quiet bool
|
||||
progressCallback func(BuildProgress)
|
||||
envOverrides map[string]string
|
||||
}
|
||||
|
||||
// BuildProgress represents build progress state.
|
||||
type BuildProgress struct {
|
||||
Step int
|
||||
Total int
|
||||
Message string
|
||||
}
|
||||
|
||||
// NewBuilder creates a new builder
|
||||
|
|
@ -31,36 +41,51 @@ func (b *Builder) Build(recipe *recipe.Recipe) error {
|
|||
return fmt.Errorf("recipe cannot be nil")
|
||||
}
|
||||
|
||||
totalSteps := 4 + len(recipe.Build) + len(recipe.Install)
|
||||
step := 0
|
||||
nextStep := func(message string) {
|
||||
step++
|
||||
b.reportProgress(step, totalSteps, message)
|
||||
}
|
||||
|
||||
// Create working directories
|
||||
sourceDir := recipe.GetSourceDir(b.workDir)
|
||||
stagingDir := recipe.GetStagingDir(b.workDir)
|
||||
packageDir := recipe.GetPackageDir(b.workDir)
|
||||
|
||||
nextStep("Preparing directories")
|
||||
if err := b.prepareDirectories(sourceDir, stagingDir, packageDir); err != nil {
|
||||
return fmt.Errorf("failed to prepare directories: %w", err)
|
||||
}
|
||||
|
||||
// Download and extract source
|
||||
nextStep("Downloading and extracting source")
|
||||
if err := b.downloadAndExtract(recipe); err != nil {
|
||||
return fmt.Errorf("failed to download and extract source: %w", err)
|
||||
}
|
||||
|
||||
// Apply patches
|
||||
nextStep("Applying recipe patches")
|
||||
if err := b.applyPatches(recipe, sourceDir); err != nil {
|
||||
return fmt.Errorf("failed to apply patches: %w", err)
|
||||
}
|
||||
|
||||
// Build package
|
||||
if err := b.executeBuild(recipe, sourceDir, stagingDir); err != nil {
|
||||
if err := b.executeBuild(recipe, sourceDir, stagingDir, func(i, total int) {
|
||||
nextStep(fmt.Sprintf("Build step %d/%d", i, total))
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to build package: %w", err)
|
||||
}
|
||||
|
||||
// Package files
|
||||
if err := b.packageFiles(recipe, sourceDir, stagingDir); err != nil {
|
||||
if err := b.packageFiles(recipe, sourceDir, stagingDir, func(i, total int) {
|
||||
nextStep(fmt.Sprintf("Install step %d/%d", i, total))
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to package files: %w", err)
|
||||
}
|
||||
|
||||
// Create package archive
|
||||
nextStep("Creating package archive")
|
||||
p := packager.NewPackager(b.workDir)
|
||||
if err := p.Package(recipe); err != nil {
|
||||
return fmt.Errorf("failed to create package archive: %w", err)
|
||||
|
|
@ -133,7 +158,7 @@ func (b *Builder) applyPatches(recipe *recipe.Recipe, sourceDir string) error {
|
|||
}
|
||||
|
||||
// executeBuild executes build commands
|
||||
func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir string) error {
|
||||
func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir string, progressFn func(step, total int)) error {
|
||||
// Find the source directory (usually the first subdirectory)
|
||||
srcPath, err := b.findSourceDirectory(sourceDir)
|
||||
if err != nil {
|
||||
|
|
@ -145,6 +170,10 @@ func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir stri
|
|||
|
||||
// Execute build commands
|
||||
for i, cmd := range recipe.Build {
|
||||
if progressFn != nil {
|
||||
progressFn(i+1, len(recipe.Build))
|
||||
}
|
||||
|
||||
// Substitute variables in command
|
||||
cmd = b.substituteVariables(cmd, sourceDir, stagingDir)
|
||||
|
||||
|
|
@ -192,7 +221,7 @@ func (b *Builder) buildEnvironment(stagingDir string) []string {
|
|||
// Add parallel build variable
|
||||
env = append(env, fmt.Sprintf("MAKEFLAGS=-j%d", runtime.NumCPU()))
|
||||
|
||||
return env
|
||||
return applyEnvOverrides(env, b.envOverrides)
|
||||
}
|
||||
|
||||
// executeCommand executes a single command
|
||||
|
|
@ -204,6 +233,19 @@ func (b *Builder) executeCommand(workDir, command string, env []string) error {
|
|||
cmd := exec.Command("sh", "-c", command)
|
||||
cmd.Dir = workDir
|
||||
cmd.Env = env
|
||||
|
||||
if b.quiet {
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
details := tailOutput(string(output), 20)
|
||||
if details != "" {
|
||||
return fmt.Errorf("%w\n%s", err, details)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
|
|
@ -308,6 +350,53 @@ func (b *Builder) SetWorkDir(workDir string) {
|
|||
b.workDir = workDir
|
||||
}
|
||||
|
||||
// SetQuiet enables or disables command output streaming.
|
||||
func (b *Builder) SetQuiet(quiet bool) {
|
||||
b.quiet = quiet
|
||||
}
|
||||
|
||||
// SetProgressCallback sets callback for build progress updates.
|
||||
func (b *Builder) SetProgressCallback(callback func(BuildProgress)) {
|
||||
b.progressCallback = callback
|
||||
}
|
||||
|
||||
// SetEnvOverride sets or removes one environment override used for build commands.
|
||||
func (b *Builder) SetEnvOverride(key, value string) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if b.envOverrides == nil {
|
||||
b.envOverrides = make(map[string]string)
|
||||
}
|
||||
if value == "" {
|
||||
delete(b.envOverrides, key)
|
||||
return
|
||||
}
|
||||
b.envOverrides[key] = value
|
||||
}
|
||||
|
||||
// SetEnvOverrides replaces all environment overrides used for build commands.
|
||||
func (b *Builder) SetEnvOverrides(overrides map[string]string) {
|
||||
if len(overrides) == 0 {
|
||||
b.envOverrides = nil
|
||||
return
|
||||
}
|
||||
cloned := make(map[string]string, len(overrides))
|
||||
for key, value := range overrides {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
cloned[key] = value
|
||||
}
|
||||
if len(cloned) == 0 {
|
||||
b.envOverrides = nil
|
||||
return
|
||||
}
|
||||
b.envOverrides = cloned
|
||||
}
|
||||
|
||||
// substituteVariables substitutes variables in command strings
|
||||
func (b *Builder) substituteVariables(cmdStr, sourceDir, stagingDir string) string {
|
||||
// Get number of CPU cores
|
||||
|
|
@ -325,7 +414,7 @@ func (b *Builder) substituteVariables(cmdStr, sourceDir, stagingDir string) stri
|
|||
}
|
||||
|
||||
// packageFiles runs the package commands
|
||||
func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir string) error {
|
||||
func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir string, progressFn func(step, total int)) error {
|
||||
// Find the source directory (usually the first subdirectory)
|
||||
srcPath, err := b.findSourceDirectory(sourceDir)
|
||||
if err != nil {
|
||||
|
|
@ -337,6 +426,10 @@ func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir stri
|
|||
|
||||
// Execute package commands
|
||||
for i, cmd := range recipe.Install {
|
||||
if progressFn != nil {
|
||||
progressFn(i+1, len(recipe.Install))
|
||||
}
|
||||
|
||||
// Substitute variables in command
|
||||
cmd = b.substituteVariables(cmd, sourceDir, stagingDir)
|
||||
|
||||
|
|
@ -347,3 +440,53 @@ func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir stri
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Builder) reportProgress(step, total int, message string) {
|
||||
if b.progressCallback == nil {
|
||||
return
|
||||
}
|
||||
b.progressCallback(BuildProgress{
|
||||
Step: step,
|
||||
Total: total,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
func tailOutput(output string, maxLines int) string {
|
||||
output = strings.TrimSpace(output)
|
||||
if output == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(output, "\n")
|
||||
if maxLines <= 0 || len(lines) <= maxLines {
|
||||
return output
|
||||
}
|
||||
return strings.Join(lines[len(lines)-maxLines:], "\n")
|
||||
}
|
||||
|
||||
func applyEnvOverrides(base []string, overrides map[string]string) []string {
|
||||
if len(overrides) == 0 {
|
||||
return base
|
||||
}
|
||||
|
||||
out := append([]string(nil), base...)
|
||||
indexByKey := make(map[string]int, len(out))
|
||||
for i, entry := range out {
|
||||
if eq := strings.IndexByte(entry, '='); eq > 0 {
|
||||
indexByKey[entry[:eq]] = i
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range overrides {
|
||||
item := key + "=" + value
|
||||
if idx, ok := indexByKey[key]; ok {
|
||||
out[idx] = item
|
||||
continue
|
||||
}
|
||||
indexByKey[key] = len(out)
|
||||
out = append(out, item)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ type sourceRecord struct {
|
|||
Directory string
|
||||
DSCName string
|
||||
DSCSHA256 string
|
||||
Binaries []string
|
||||
}
|
||||
|
||||
func (r *Resolver) findPackageInIndex(mirror, suite, component, pkg string) (*sourceRecord, error) {
|
||||
|
|
@ -203,7 +204,7 @@ func (r *Resolver) findInSingleIndex(indexURL string, decoder func(io.Reader) (i
|
|||
if err != nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if rec.Package == pkg {
|
||||
if rec.Package == pkg || containsSourceBinary(rec.Binaries, pkg) {
|
||||
return &rec, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
|
|
@ -294,9 +295,41 @@ func parseSourcesParagraph(lines []string) (sourceRecord, error) {
|
|||
Directory: dir,
|
||||
DSCName: dscName,
|
||||
DSCSHA256: dscHash,
|
||||
Binaries: parseCommaSeparatedField(fields["Binary"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseCommaSeparatedField(raw string) []string {
|
||||
parts := strings.Split(strings.TrimSpace(raw), ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(strings.ToLower(part))
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[part]; exists {
|
||||
continue
|
||||
}
|
||||
seen[part] = struct{}{}
|
||||
out = append(out, part)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsSourceBinary(names []string, wanted string) bool {
|
||||
wanted = strings.TrimSpace(strings.ToLower(wanted))
|
||||
if wanted == "" {
|
||||
return false
|
||||
}
|
||||
for _, name := range names {
|
||||
if strings.TrimSpace(strings.ToLower(name)) == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseChecksumsForDSC(raw string) (string, string) {
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
fields := strings.Fields(strings.TrimSpace(line))
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ func TestResolveSourceHTTPFallbackToGzip(t *testing.T) {
|
|||
|
||||
sources := strings.TrimSpace(`
|
||||
Package: neofetch
|
||||
Binary: neofetch
|
||||
Version: 7.1.0-4
|
||||
Directory: pool/main/n/neofetch
|
||||
Checksums-Sha256:
|
||||
|
|
@ -77,6 +78,48 @@ Checksums-Sha256:
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveSourceByBinaryName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sources := strings.TrimSpace(`
|
||||
Package: pkgconf
|
||||
Binary: pkgconf, pkg-config
|
||||
Version: 2.0.3-1
|
||||
Directory: pool/main/p/pkgconf
|
||||
Checksums-Sha256:
|
||||
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc 1800 pkgconf_2.0.3-1.dsc
|
||||
`) + "\n"
|
||||
|
||||
client := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if strings.HasSuffix(req.URL.Path, "/Sources") {
|
||||
return httpResponse(req, http.StatusOK, []byte(sources)), nil
|
||||
}
|
||||
return httpResponse(req, http.StatusNotFound, []byte("missing")), nil
|
||||
}),
|
||||
}
|
||||
|
||||
r := NewResolver(
|
||||
WithHTTPClient(client),
|
||||
WithMirrors([]string{"https://mirror.example/debian"}),
|
||||
WithSuites([]string{"stable"}),
|
||||
WithComponents([]string{"main"}),
|
||||
)
|
||||
|
||||
info, err := r.ResolveSource("pkg-config")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSource() error = %v", err)
|
||||
}
|
||||
|
||||
if info.SourcePackage != "pkgconf" {
|
||||
t.Fatalf("unexpected source package: %s", info.SourcePackage)
|
||||
}
|
||||
wantURL := "https://mirror.example/debian/pool/main/p/pkgconf/pkgconf_2.0.3-1.dsc"
|
||||
if info.DSCURL != wantURL {
|
||||
t.Fatalf("unexpected dsc url:\nwant: %s\ngot: %s", wantURL, info.DSCURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSourceHTTPNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
411
pkg/deps/deps.go
Normal file
411
pkg/deps/deps.go
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
package deps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// VersionOp defines version comparison operator for dependency constraints.
|
||||
type VersionOp int
|
||||
|
||||
const (
|
||||
OpAny VersionOp = iota
|
||||
OpEqual
|
||||
OpGreater
|
||||
OpGreaterOrEqual
|
||||
OpLess
|
||||
OpLessOrEqual
|
||||
)
|
||||
|
||||
// Constraint describes one dependency alternative like "pkg>=1.2".
|
||||
type Constraint struct {
|
||||
Name string
|
||||
Op VersionOp
|
||||
Version string
|
||||
}
|
||||
|
||||
// Requirement describes one dependency expression with alternatives: "a | b>=2".
|
||||
type Requirement struct {
|
||||
Raw string
|
||||
Alternatives []Constraint
|
||||
}
|
||||
|
||||
// ParseRequirements parses dependency expressions.
|
||||
func ParseRequirements(raw []string) ([]Requirement, error) {
|
||||
reqs := make([]Requirement, 0, len(raw))
|
||||
for _, entry := range raw {
|
||||
req, err := ParseRequirement(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqs = append(reqs, req)
|
||||
}
|
||||
return reqs, nil
|
||||
}
|
||||
|
||||
// ParseRequirement parses one dependency expression.
|
||||
func ParseRequirement(raw string) (Requirement, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return Requirement{}, fmt.Errorf("dependency cannot be empty")
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, "|")
|
||||
alts := make([]Constraint, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
c, err := parseConstraint(part)
|
||||
if err != nil {
|
||||
return Requirement{}, fmt.Errorf("invalid dependency %q: %w", raw, err)
|
||||
}
|
||||
alts = append(alts, c)
|
||||
}
|
||||
if len(alts) == 0 {
|
||||
return Requirement{}, fmt.Errorf("invalid dependency %q", raw)
|
||||
}
|
||||
|
||||
return Requirement{
|
||||
Raw: raw,
|
||||
Alternatives: alts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseConstraint(raw string) (Constraint, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return Constraint{}, fmt.Errorf("empty alternative")
|
||||
}
|
||||
|
||||
// Debian style: "pkg (>= 1.2)".
|
||||
if open := strings.IndexByte(raw, '('); open >= 0 {
|
||||
close := strings.LastIndexByte(raw, ')')
|
||||
if close < open || close != len(raw)-1 {
|
||||
return Constraint{}, fmt.Errorf("invalid parentheses syntax")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(raw[:open])
|
||||
if !isValidDepName(name) {
|
||||
return Constraint{}, fmt.Errorf("invalid package name %q", name)
|
||||
}
|
||||
|
||||
inner := strings.TrimSpace(raw[open+1 : close])
|
||||
op, version, err := splitVersionConstraint(inner)
|
||||
if err != nil {
|
||||
return Constraint{}, err
|
||||
}
|
||||
return Constraint{Name: name, Op: op, Version: version}, nil
|
||||
}
|
||||
|
||||
// Generic style: "pkg>=1.2" or "pkg >= 1.2" or plain "pkg".
|
||||
name, op, version, err := splitNameAndConstraint(raw)
|
||||
if err != nil {
|
||||
return Constraint{}, err
|
||||
}
|
||||
return Constraint{Name: name, Op: op, Version: version}, nil
|
||||
}
|
||||
|
||||
func splitNameAndConstraint(raw string) (string, VersionOp, string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
op := OpAny
|
||||
version := ""
|
||||
|
||||
idx, opFound := findOp(raw)
|
||||
if opFound {
|
||||
name = strings.TrimSpace(raw[:idx])
|
||||
right := strings.TrimSpace(raw[idx:])
|
||||
var err error
|
||||
op, version, err = splitVersionConstraint(right)
|
||||
if err != nil {
|
||||
return "", OpAny, "", err
|
||||
}
|
||||
}
|
||||
|
||||
if !isValidDepName(name) {
|
||||
return "", OpAny, "", fmt.Errorf("invalid package name %q", name)
|
||||
}
|
||||
return name, op, version, nil
|
||||
}
|
||||
|
||||
func findOp(raw string) (int, bool) {
|
||||
for i, r := range raw {
|
||||
switch r {
|
||||
case '<', '>', '=':
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func splitVersionConstraint(raw string) (VersionOp, string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return OpAny, "", fmt.Errorf("empty version constraint")
|
||||
}
|
||||
|
||||
opToken := ""
|
||||
version := ""
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(raw, ">="):
|
||||
opToken = ">="
|
||||
version = strings.TrimSpace(raw[2:])
|
||||
case strings.HasPrefix(raw, "<="):
|
||||
opToken = "<="
|
||||
version = strings.TrimSpace(raw[2:])
|
||||
case strings.HasPrefix(raw, "="):
|
||||
opToken = "="
|
||||
version = strings.TrimSpace(raw[1:])
|
||||
case strings.HasPrefix(raw, ">"):
|
||||
opToken = ">"
|
||||
version = strings.TrimSpace(raw[1:])
|
||||
case strings.HasPrefix(raw, "<"):
|
||||
opToken = "<"
|
||||
version = strings.TrimSpace(raw[1:])
|
||||
default:
|
||||
parts := strings.Fields(raw)
|
||||
if len(parts) == 2 {
|
||||
opToken = parts[0]
|
||||
version = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
if opToken == "" || version == "" {
|
||||
return OpAny, "", fmt.Errorf("invalid version constraint %q", raw)
|
||||
}
|
||||
if strings.HasPrefix(version, "<") || strings.HasPrefix(version, ">") || strings.HasPrefix(version, "=") {
|
||||
return OpAny, "", fmt.Errorf("invalid version %q", version)
|
||||
}
|
||||
if strings.ContainsAny(version, "()|") {
|
||||
return OpAny, "", fmt.Errorf("invalid version %q", version)
|
||||
}
|
||||
|
||||
op, err := parseVersionOp(opToken)
|
||||
if err != nil {
|
||||
return OpAny, "", err
|
||||
}
|
||||
return op, version, nil
|
||||
}
|
||||
|
||||
func parseVersionOp(raw string) (VersionOp, error) {
|
||||
switch strings.TrimSpace(raw) {
|
||||
case "=":
|
||||
return OpEqual, nil
|
||||
case ">":
|
||||
return OpGreater, nil
|
||||
case ">=":
|
||||
return OpGreaterOrEqual, nil
|
||||
case "<":
|
||||
return OpLess, nil
|
||||
case "<=":
|
||||
return OpLessOrEqual, nil
|
||||
default:
|
||||
return OpAny, fmt.Errorf("unsupported operator %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func isValidDepName(name string) bool {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range name {
|
||||
if i == 0 {
|
||||
if !unicode.IsDigit(r) && !unicode.IsLetter(r) {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '+', '.', '-', '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MatchesVersion checks whether package version satisfies this constraint.
|
||||
func (c Constraint) MatchesVersion(installedVersion string) bool {
|
||||
switch c.Op {
|
||||
case OpAny:
|
||||
return true
|
||||
case OpEqual:
|
||||
return CompareVersions(installedVersion, c.Version) == 0
|
||||
case OpGreater:
|
||||
return CompareVersions(installedVersion, c.Version) > 0
|
||||
case OpGreaterOrEqual:
|
||||
return CompareVersions(installedVersion, c.Version) >= 0
|
||||
case OpLess:
|
||||
return CompareVersions(installedVersion, c.Version) < 0
|
||||
case OpLessOrEqual:
|
||||
return CompareVersions(installedVersion, c.Version) <= 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// CompareVersions compares package versions.
|
||||
// Returns -1 if a < b, 0 if a == b, and 1 if a > b.
|
||||
func CompareVersions(a, b string) int {
|
||||
epochA, restA := splitEpoch(a)
|
||||
epochB, restB := splitEpoch(b)
|
||||
if epochA != epochB {
|
||||
if epochA < epochB {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
mainA, relA := splitRelease(restA)
|
||||
mainB, relB := splitRelease(restB)
|
||||
|
||||
if c := compareVersionPart(mainA, mainB); c != 0 {
|
||||
return c
|
||||
}
|
||||
return compareVersionPart(relA, relB)
|
||||
}
|
||||
|
||||
func splitEpoch(raw string) (int64, string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if idx := strings.IndexByte(raw, ':'); idx > 0 {
|
||||
epochRaw := strings.TrimSpace(raw[:idx])
|
||||
if epoch, err := strconv.ParseInt(epochRaw, 10, 64); err == nil {
|
||||
return epoch, strings.TrimSpace(raw[idx+1:])
|
||||
}
|
||||
}
|
||||
return 0, raw
|
||||
}
|
||||
|
||||
func splitRelease(raw string) (string, string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "0", "0"
|
||||
}
|
||||
|
||||
if idx := strings.LastIndex(raw, "-"); idx > 0 && idx < len(raw)-1 {
|
||||
return raw[:idx], raw[idx+1:]
|
||||
}
|
||||
return raw, "0"
|
||||
}
|
||||
|
||||
func compareVersionPart(a, b string) int {
|
||||
a = strings.TrimSpace(a)
|
||||
b = strings.TrimSpace(b)
|
||||
|
||||
i, j := 0, 0
|
||||
for i < len(a) || j < len(b) {
|
||||
// Tilde sorts before everything, including end of string.
|
||||
if i < len(a) && a[i] == '~' || j < len(b) && b[j] == '~' {
|
||||
switch {
|
||||
case i < len(a) && a[i] == '~' && j < len(b) && b[j] == '~':
|
||||
i++
|
||||
j++
|
||||
continue
|
||||
case i < len(a) && a[i] == '~':
|
||||
return -1
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
for i < len(a) && isVersionSeparator(a[i]) {
|
||||
i++
|
||||
}
|
||||
for j < len(b) && isVersionSeparator(b[j]) {
|
||||
j++
|
||||
}
|
||||
|
||||
if i >= len(a) && j >= len(b) {
|
||||
return 0
|
||||
}
|
||||
if i >= len(a) {
|
||||
return -1
|
||||
}
|
||||
if j >= len(b) {
|
||||
return 1
|
||||
}
|
||||
|
||||
aNum := isDigit(a[i])
|
||||
bNum := isDigit(b[j])
|
||||
|
||||
segA, nextI := readVersionSegment(a, i, aNum)
|
||||
segB, nextJ := readVersionSegment(b, j, bNum)
|
||||
i, j = nextI, nextJ
|
||||
|
||||
var c int
|
||||
switch {
|
||||
case aNum && bNum:
|
||||
c = compareNumericSegment(segA, segB)
|
||||
case aNum && !bNum:
|
||||
c = 1
|
||||
case !aNum && bNum:
|
||||
c = -1
|
||||
default:
|
||||
c = strings.Compare(strings.ToLower(segA), strings.ToLower(segB))
|
||||
}
|
||||
if c < 0 {
|
||||
return -1
|
||||
}
|
||||
if c > 0 {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func isVersionSeparator(ch byte) bool {
|
||||
if ch == '~' {
|
||||
return false
|
||||
}
|
||||
return !isDigit(ch) && !isLetter(ch)
|
||||
}
|
||||
|
||||
func readVersionSegment(s string, start int, numeric bool) (string, int) {
|
||||
i := start
|
||||
for i < len(s) {
|
||||
ch := s[i]
|
||||
if numeric {
|
||||
if !isDigit(ch) {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if !isLetter(ch) {
|
||||
break
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
return s[start:i], i
|
||||
}
|
||||
|
||||
func compareNumericSegment(a, b string) int {
|
||||
a = strings.TrimLeft(a, "0")
|
||||
b = strings.TrimLeft(b, "0")
|
||||
if a == "" {
|
||||
a = "0"
|
||||
}
|
||||
if b == "" {
|
||||
b = "0"
|
||||
}
|
||||
|
||||
if len(a) < len(b) {
|
||||
return -1
|
||||
}
|
||||
if len(a) > len(b) {
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(a, b)
|
||||
}
|
||||
|
||||
func isDigit(ch byte) bool {
|
||||
return ch >= '0' && ch <= '9'
|
||||
}
|
||||
|
||||
func isLetter(ch byte) bool {
|
||||
return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z'
|
||||
}
|
||||
134
pkg/deps/deps_test.go
Normal file
134
pkg/deps/deps_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package deps
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRequirement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req, err := ParseRequirement("lua5.1 (>= 5.1.5) | luajit")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRequirement() error = %v", err)
|
||||
}
|
||||
|
||||
if len(req.Alternatives) != 2 {
|
||||
t.Fatalf("expected 2 alternatives, got %d", len(req.Alternatives))
|
||||
}
|
||||
|
||||
first := req.Alternatives[0]
|
||||
if first.Name != "lua5.1" || first.Op != OpGreaterOrEqual || first.Version != "5.1.5" {
|
||||
t.Fatalf("unexpected first alt: %#v", first)
|
||||
}
|
||||
second := req.Alternatives[1]
|
||||
if second.Name != "luajit" || second.Op != OpAny {
|
||||
t.Fatalf("unexpected second alt: %#v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRequirementsInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
" ",
|
||||
"bad dep",
|
||||
"foo (>> 1)",
|
||||
"foo >=",
|
||||
"foo |",
|
||||
"| foo",
|
||||
}
|
||||
|
||||
for _, raw := range invalid {
|
||||
if _, err := ParseRequirement(raw); err == nil {
|
||||
t.Fatalf("expected parse error for %q", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"1.0", "1.0", 0},
|
||||
{"1.0", "1.0.1", -1},
|
||||
{"2.0", "1.9", 1},
|
||||
{"1.02", "1.2", 0},
|
||||
{"1.0~rc1", "1.0", -1},
|
||||
{"1:1.0-1", "1.9.9", 1},
|
||||
{"2:1.0-1", "1:9.0-9", 1},
|
||||
{"1.0-2", "1.0-10", -1},
|
||||
{"1.0+dfsg1", "1.0+dfsg2", -1},
|
||||
{"1.0a", "1.0", 1},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := CompareVersions(tc.a, tc.b)
|
||||
if sign(got) != sign(tc.want) {
|
||||
t.Fatalf("CompareVersions(%q, %q): want sign %d, got %d", tc.a, tc.b, sign(tc.want), sign(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstraintMatchesVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
rawDep string
|
||||
version string
|
||||
want bool
|
||||
}{
|
||||
{"foo", "1.0", true},
|
||||
{"foo=1.0", "1.0", true},
|
||||
{"foo=1.0", "1.1", false},
|
||||
{"foo>=1.0", "1.2", true},
|
||||
{"foo>=1.0", "0.9", false},
|
||||
{"foo<2.0", "1.9", true},
|
||||
{"foo<2.0", "2.0", false},
|
||||
{"foo (<= 2.1)", "2.1", true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
req, err := ParseRequirement(tc.rawDep)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRequirement(%q) error = %v", tc.rawDep, err)
|
||||
}
|
||||
got := req.Alternatives[0].MatchesVersion(tc.version)
|
||||
if got != tc.want {
|
||||
t.Fatalf("MatchesVersion(%q, %q): want %v, got %v", tc.rawDep, tc.version, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRequirements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := []string{"foo", "bar>=1.2", "baz | qux"}
|
||||
got, err := ParseRequirements(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRequirements() error = %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3 requirements, got %d", len(got))
|
||||
}
|
||||
|
||||
names := []string{got[0].Alternatives[0].Name, got[1].Alternatives[0].Name, got[2].Alternatives[0].Name}
|
||||
if want := []string{"foo", "bar", "baz"}; !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("unexpected parsed names:\nwant: %#v\ngot: %#v", want, names)
|
||||
}
|
||||
}
|
||||
|
||||
func sign(v int) int {
|
||||
switch {
|
||||
case v < 0:
|
||||
return -1
|
||||
case v > 0:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package installer
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
|
@ -9,6 +10,7 @@ import (
|
|||
"strings"
|
||||
"syscall"
|
||||
|
||||
"zsvo/pkg/deps"
|
||||
"zsvo/pkg/packager"
|
||||
"zsvo/pkg/types"
|
||||
)
|
||||
|
|
@ -153,48 +155,22 @@ func (i *Installer) installManyNoLock(packagePaths []string) error {
|
|||
defer os.RemoveAll(txDir)
|
||||
|
||||
applied := make([]installTransactionState, 0, len(candidates))
|
||||
remaining := append([]installCandidate(nil), candidates...)
|
||||
ordered, err := solveInstallOrder(candidates, installedInfos)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for len(remaining) > 0 {
|
||||
progress := false
|
||||
for idx, candidate := range remaining {
|
||||
if !depsSatisfied(candidate.info.Dependencies, installedInfos, candidate.info.Name) {
|
||||
continue
|
||||
for _, candidate := range ordered {
|
||||
state, err := i.installPackageTxNoLock(candidate.path, txDir, installedInfos)
|
||||
if err != nil {
|
||||
if rbErr := i.rollbackInstallTransactionNoLock(applied); rbErr != nil {
|
||||
return fmt.Errorf("install failed for %s: %w (rollback error: %v)", candidate.info.Name, err, rbErr)
|
||||
}
|
||||
|
||||
state, err := i.installPackageTxNoLock(candidate.path, txDir, installedInfos)
|
||||
if err != nil {
|
||||
if rbErr := i.rollbackInstallTransactionNoLock(applied); rbErr != nil {
|
||||
return fmt.Errorf("install failed for %s: %w (rollback error: %v)", candidate.info.Name, err, rbErr)
|
||||
}
|
||||
return fmt.Errorf("install failed for %s: %w", candidate.info.Name, err)
|
||||
}
|
||||
|
||||
applied = append(applied, *state)
|
||||
installedInfos[candidate.info.Name] = candidate.info
|
||||
remaining = append(remaining[:idx], remaining[idx+1:]...)
|
||||
progress = true
|
||||
break
|
||||
return fmt.Errorf("install failed for %s: %w", candidate.info.Name, err)
|
||||
}
|
||||
|
||||
if progress {
|
||||
continue
|
||||
}
|
||||
|
||||
block := remaining[0]
|
||||
missing := missingDeps(block.info.Dependencies, installedInfos, block.info.Name)
|
||||
if len(missing) == 0 {
|
||||
missing = []string{"unknown ordering issue"}
|
||||
}
|
||||
if rbErr := i.rollbackInstallTransactionNoLock(applied); rbErr != nil {
|
||||
return fmt.Errorf(
|
||||
"cannot resolve install order for %s: missing deps %s (rollback error: %v)",
|
||||
block.info.Name,
|
||||
strings.Join(missing, ", "),
|
||||
rbErr,
|
||||
)
|
||||
}
|
||||
return fmt.Errorf("cannot resolve install order for %s: missing deps %s", block.info.Name, strings.Join(missing, ", "))
|
||||
applied = append(applied, *state)
|
||||
installedInfos[candidate.info.Name] = candidate.info
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -844,47 +820,50 @@ func removePathIfExists(path string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateSimpleDependencies(deps []string) error {
|
||||
for _, dep := range deps {
|
||||
if depNameFromConstraint(dep) == "" {
|
||||
return fmt.Errorf("invalid dependency: %q", dep)
|
||||
}
|
||||
func validateSimpleDependencies(rawDeps []string) error {
|
||||
if _, err := deps.ParseRequirements(rawDeps); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func depsSatisfied(deps []string, installedInfos map[string]*types.PkgInfo, selfName string) bool {
|
||||
for _, dep := range deps {
|
||||
depName := depNameFromConstraint(dep)
|
||||
if depName == "" {
|
||||
return false
|
||||
}
|
||||
if depName == selfName {
|
||||
continue
|
||||
}
|
||||
if _, ok := installedInfos[depName]; !ok {
|
||||
func depsSatisfied(rawDeps []string, installedInfos map[string]*types.PkgInfo, selfName string) bool {
|
||||
reqs, err := deps.ParseRequirements(rawDeps)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
available := packageVersions(installedInfos)
|
||||
for _, req := range reqs {
|
||||
if _, ok := resolveRequirementProvider(req, available, nil, selfName); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func missingDeps(deps []string, installedInfos map[string]*types.PkgInfo, selfName string) []string {
|
||||
func missingDeps(rawDeps []string, installedInfos map[string]*types.PkgInfo, selfName string) []string {
|
||||
reqs, err := deps.ParseRequirements(rawDeps)
|
||||
if err != nil {
|
||||
return []string{err.Error()}
|
||||
}
|
||||
|
||||
missing := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, dep := range deps {
|
||||
depName := depNameFromConstraint(dep)
|
||||
if depName == "" || depName == selfName {
|
||||
available := packageVersions(installedInfos)
|
||||
for _, req := range reqs {
|
||||
if _, ok := resolveRequirementProvider(req, available, nil, selfName); ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := installedInfos[depName]; ok {
|
||||
label := req.Raw
|
||||
if label == "" {
|
||||
label = formatRequirement(req)
|
||||
}
|
||||
if _, exists := seen[label]; exists {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[depName]; exists {
|
||||
continue
|
||||
}
|
||||
seen[depName] = struct{}{}
|
||||
missing = append(missing, depName)
|
||||
seen[label] = struct{}{}
|
||||
missing = append(missing, label)
|
||||
}
|
||||
sort.Strings(missing)
|
||||
return missing
|
||||
|
|
@ -901,27 +880,6 @@ func checkDependenciesAgainstInstalled(deps []string, installedInfos map[string]
|
|||
return fmt.Errorf("dependency not installed: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
func depNameFromConstraint(dep string) string {
|
||||
dep = strings.TrimSpace(dep)
|
||||
if dep == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if idx := strings.Index(dep, "|"); idx >= 0 {
|
||||
dep = strings.TrimSpace(dep[:idx])
|
||||
}
|
||||
|
||||
for idx, r := range dep {
|
||||
switch r {
|
||||
case '<', '>', '=', ' ', '\t', '\n', '\r':
|
||||
dep = strings.TrimSpace(dep[:idx])
|
||||
return dep
|
||||
}
|
||||
}
|
||||
|
||||
return dep
|
||||
}
|
||||
|
||||
func normalizePackageNames(packageNames []string) ([]string, error) {
|
||||
if len(packageNames) == 0 {
|
||||
return nil, fmt.Errorf("no packages provided")
|
||||
|
|
@ -961,17 +919,15 @@ func brokenPackagesAfterRemoval(infos map[string]*types.PkgInfo, removeSet map[s
|
|||
sort.Strings(names)
|
||||
|
||||
broken := make([]string, 0)
|
||||
remainingVersions := packageVersions(remaining)
|
||||
for _, pkgName := range names {
|
||||
pkgInfo := remaining[pkgName]
|
||||
for _, dep := range pkgInfo.Dependencies {
|
||||
depName := depNameFromConstraint(dep)
|
||||
if depName == "" {
|
||||
return nil, fmt.Errorf("invalid dependency in package %s: %q", pkgName, dep)
|
||||
}
|
||||
if depName == pkgName {
|
||||
continue
|
||||
}
|
||||
if _, ok := remaining[depName]; !ok {
|
||||
reqs, err := deps.ParseRequirements(pkgInfo.Dependencies)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid dependency in package %s: %w", pkgName, err)
|
||||
}
|
||||
for _, req := range reqs {
|
||||
if _, ok := resolveRequirementProvider(req, remainingVersions, nil, pkgName); !ok {
|
||||
broken = append(broken, pkgName)
|
||||
break
|
||||
}
|
||||
|
|
@ -983,15 +939,18 @@ func brokenPackagesAfterRemoval(infos map[string]*types.PkgInfo, removeSet map[s
|
|||
|
||||
func listOrphansFromInfos(infos map[string]*types.PkgInfo) []string {
|
||||
required := make(map[string]struct{})
|
||||
versions := packageVersions(infos)
|
||||
for owner, info := range infos {
|
||||
for _, dep := range info.Dependencies {
|
||||
depName := depNameFromConstraint(dep)
|
||||
if depName == "" || depName == owner {
|
||||
reqs, err := deps.ParseRequirements(info.Dependencies)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, req := range reqs {
|
||||
provider, ok := resolveRequirementProvider(req, versions, nil, owner)
|
||||
if !ok || provider == owner {
|
||||
continue
|
||||
}
|
||||
if _, ok := infos[depName]; ok {
|
||||
required[depName] = struct{}{}
|
||||
}
|
||||
required[provider] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1005,6 +964,191 @@ func listOrphansFromInfos(infos map[string]*types.PkgInfo) []string {
|
|||
return orphans
|
||||
}
|
||||
|
||||
func solveInstallOrder(candidates []installCandidate, installedInfos map[string]*types.PkgInfo) ([]installCandidate, error) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
candidateByName := make(map[string]installCandidate, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidate.info == nil {
|
||||
return nil, fmt.Errorf("invalid install candidate: nil metadata")
|
||||
}
|
||||
if prev, exists := candidateByName[candidate.info.Name]; exists {
|
||||
return nil, fmt.Errorf("duplicate package %s in transaction: %s and %s", candidate.info.Name, prev.path, candidate.path)
|
||||
}
|
||||
candidateByName[candidate.info.Name] = candidate
|
||||
}
|
||||
|
||||
available := packageVersions(installedInfos)
|
||||
for _, candidate := range candidates {
|
||||
available[candidate.info.Name] = candidate.info.Version
|
||||
}
|
||||
|
||||
indegree := make(map[string]int, len(candidates))
|
||||
dependents := make(map[string][]string, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
indegree[candidate.info.Name] = 0
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
reqs, err := deps.ParseRequirements(candidate.info.Dependencies)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid dependencies in %s: %w", candidate.info.Name, err)
|
||||
}
|
||||
|
||||
edgeSet := make(map[string]struct{})
|
||||
for _, req := range reqs {
|
||||
provider, ok := resolveRequirementProvider(req, available, candidateByName, candidate.info.Name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cannot resolve dependencies for %s: missing %s", candidate.info.Name, req.Raw)
|
||||
}
|
||||
if provider == candidate.info.Name {
|
||||
continue
|
||||
}
|
||||
if _, inTx := candidateByName[provider]; !inTx {
|
||||
continue
|
||||
}
|
||||
if _, seen := edgeSet[provider]; seen {
|
||||
continue
|
||||
}
|
||||
edgeSet[provider] = struct{}{}
|
||||
dependents[provider] = append(dependents[provider], candidate.info.Name)
|
||||
indegree[candidate.info.Name]++
|
||||
}
|
||||
}
|
||||
|
||||
ready := &stringMinHeap{}
|
||||
heap.Init(ready)
|
||||
for name, deg := range indegree {
|
||||
if deg == 0 {
|
||||
heap.Push(ready, name)
|
||||
}
|
||||
}
|
||||
|
||||
ordered := make([]installCandidate, 0, len(candidates))
|
||||
for ready.Len() > 0 {
|
||||
name := heap.Pop(ready).(string)
|
||||
ordered = append(ordered, candidateByName[name])
|
||||
|
||||
next := dependents[name]
|
||||
sort.Strings(next)
|
||||
for _, depName := range next {
|
||||
indegree[depName]--
|
||||
if indegree[depName] == 0 {
|
||||
heap.Push(ready, depName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ordered) != len(candidates) {
|
||||
stuck := make([]string, 0)
|
||||
for name, deg := range indegree {
|
||||
if deg > 0 {
|
||||
stuck = append(stuck, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(stuck)
|
||||
return nil, fmt.Errorf("cannot resolve install order: dependency cycle among %s", strings.Join(stuck, ", "))
|
||||
}
|
||||
|
||||
return ordered, nil
|
||||
}
|
||||
|
||||
func packageVersions(infos map[string]*types.PkgInfo) map[string]string {
|
||||
out := make(map[string]string, len(infos))
|
||||
for name, info := range infos {
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
out[name] = info.Version
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveRequirementProvider(
|
||||
req deps.Requirement,
|
||||
availableVersions map[string]string,
|
||||
candidateByName map[string]installCandidate,
|
||||
selfName string,
|
||||
) (string, bool) {
|
||||
for _, alt := range req.Alternatives {
|
||||
version, ok := availableVersions[alt.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if alt.Name == selfName && candidateByName != nil {
|
||||
if selfCandidate, exists := candidateByName[selfName]; exists {
|
||||
version = selfCandidate.info.Version
|
||||
}
|
||||
}
|
||||
if !alt.MatchesVersion(version) {
|
||||
continue
|
||||
}
|
||||
|
||||
// If a package with this name is in current transaction, it replaces installed one.
|
||||
if candidateByName != nil {
|
||||
if candidate, exists := candidateByName[alt.Name]; exists {
|
||||
if alt.MatchesVersion(candidate.info.Version) {
|
||||
return candidate.info.Name, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
return alt.Name, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func formatRequirement(req deps.Requirement) string {
|
||||
if strings.TrimSpace(req.Raw) != "" {
|
||||
return strings.TrimSpace(req.Raw)
|
||||
}
|
||||
parts := make([]string, 0, len(req.Alternatives))
|
||||
for _, alt := range req.Alternatives {
|
||||
if alt.Op == deps.OpAny {
|
||||
parts = append(parts, alt.Name)
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s%s%s", alt.Name, formatOp(alt.Op), alt.Version))
|
||||
}
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func formatOp(op deps.VersionOp) string {
|
||||
switch op {
|
||||
case deps.OpEqual:
|
||||
return "="
|
||||
case deps.OpGreater:
|
||||
return ">"
|
||||
case deps.OpGreaterOrEqual:
|
||||
return ">="
|
||||
case deps.OpLess:
|
||||
return "<"
|
||||
case deps.OpLessOrEqual:
|
||||
return "<="
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type stringMinHeap []string
|
||||
|
||||
func (h stringMinHeap) Len() int { return len(h) }
|
||||
func (h stringMinHeap) Less(i, j int) bool { return h[i] < h[j] }
|
||||
func (h stringMinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
||||
func (h *stringMinHeap) Push(x interface{}) {
|
||||
*h = append(*h, x.(string))
|
||||
}
|
||||
func (h *stringMinHeap) Pop() interface{} {
|
||||
old := *h
|
||||
last := len(old) - 1
|
||||
item := old[last]
|
||||
*h = old[:last]
|
||||
return item
|
||||
}
|
||||
|
||||
func sanitizePackagePath(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("file path cannot be empty")
|
||||
|
|
|
|||
|
|
@ -142,6 +142,132 @@ func TestInstallManyRollsBackOnFailure(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInstallManyRespectsVersionConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workDir := t.TempDir()
|
||||
rootDir := t.TempDir()
|
||||
ins := NewInstaller(rootDir)
|
||||
|
||||
libPkg := buildTestPackage(t, workDir, &recipe.Recipe{
|
||||
Name: "libfoo",
|
||||
Version: "2.1.0",
|
||||
Build: []string{"true"},
|
||||
Install: []string{"true"},
|
||||
}, map[string][]byte{"usr/lib/libfoo.so": []byte("lib2\n")})
|
||||
|
||||
appPkg := buildTestPackage(t, workDir, &recipe.Recipe{
|
||||
Name: "app",
|
||||
Version: "1.0.0",
|
||||
Build: []string{"true"},
|
||||
Install: []string{"true"},
|
||||
Deps: []string{"libfoo>=2.0.0"},
|
||||
}, map[string][]byte{"usr/bin/app": []byte("app\n")})
|
||||
|
||||
if err := ins.InstallMany([]string{appPkg, libPkg}); err != nil {
|
||||
t.Fatalf("InstallMany() error = %v", err)
|
||||
}
|
||||
if !ins.IsInstalled("libfoo") || !ins.IsInstalled("app") {
|
||||
t.Fatalf("expected libfoo and app to be installed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallManyFailsOnUnsatisfiedVersionConstraint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workDir := t.TempDir()
|
||||
rootDir := t.TempDir()
|
||||
ins := NewInstaller(rootDir)
|
||||
|
||||
libOld := buildTestPackage(t, workDir, &recipe.Recipe{
|
||||
Name: "libfoo",
|
||||
Version: "1.0.0",
|
||||
Build: []string{"true"},
|
||||
Install: []string{"true"},
|
||||
}, map[string][]byte{"usr/lib/libfoo.so": []byte("lib1\n")})
|
||||
if err := ins.Install(libOld); err != nil {
|
||||
t.Fatalf("Install(libOld) error = %v", err)
|
||||
}
|
||||
|
||||
appPkg := buildTestPackage(t, workDir, &recipe.Recipe{
|
||||
Name: "app",
|
||||
Version: "1.0.0",
|
||||
Build: []string{"true"},
|
||||
Install: []string{"true"},
|
||||
Deps: []string{"libfoo>=2.0.0"},
|
||||
}, map[string][]byte{"usr/bin/app": []byte("app\n")})
|
||||
|
||||
err := ins.Install(appPkg)
|
||||
if err == nil {
|
||||
t.Fatalf("expected install error for unsatisfied version constraint")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "libfoo>=2.0.0") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallManySupportsAlternativeDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workDir := t.TempDir()
|
||||
rootDir := t.TempDir()
|
||||
ins := NewInstaller(rootDir)
|
||||
|
||||
libBarPkg := buildTestPackage(t, workDir, &recipe.Recipe{
|
||||
Name: "libbar",
|
||||
Version: "1.5.0",
|
||||
Build: []string{"true"},
|
||||
Install: []string{"true"},
|
||||
}, map[string][]byte{"usr/lib/libbar.so": []byte("bar\n")})
|
||||
|
||||
appPkg := buildTestPackage(t, workDir, &recipe.Recipe{
|
||||
Name: "app",
|
||||
Version: "1.0.0",
|
||||
Build: []string{"true"},
|
||||
Install: []string{"true"},
|
||||
Deps: []string{"libfoo | libbar>=1.2.0"},
|
||||
}, map[string][]byte{"usr/bin/app": []byte("app\n")})
|
||||
|
||||
if err := ins.InstallMany([]string{appPkg, libBarPkg}); err != nil {
|
||||
t.Fatalf("InstallMany() error = %v", err)
|
||||
}
|
||||
if !ins.IsInstalled("libbar") || !ins.IsInstalled("app") {
|
||||
t.Fatalf("expected libbar and app to be installed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallManyDetectsDependencyCycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workDir := t.TempDir()
|
||||
rootDir := t.TempDir()
|
||||
ins := NewInstaller(rootDir)
|
||||
|
||||
pkgA := buildTestPackage(t, workDir, &recipe.Recipe{
|
||||
Name: "a",
|
||||
Version: "1.0.0",
|
||||
Build: []string{"true"},
|
||||
Install: []string{"true"},
|
||||
Deps: []string{"b>=1.0"},
|
||||
}, map[string][]byte{"usr/bin/a": []byte("a\n")})
|
||||
|
||||
pkgB := buildTestPackage(t, workDir, &recipe.Recipe{
|
||||
Name: "b",
|
||||
Version: "1.0.0",
|
||||
Build: []string{"true"},
|
||||
Install: []string{"true"},
|
||||
Deps: []string{"a>=1.0"},
|
||||
}, map[string][]byte{"usr/bin/b": []byte("b\n")})
|
||||
|
||||
err := ins.InstallMany([]string{pkgA, pkgB})
|
||||
if err == nil {
|
||||
t.Fatalf("expected cycle error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dependency cycle") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRespectsRootFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -219,6 +345,43 @@ func TestRemoveManyCascade(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRemoveManyAllowsAlternativeDependencyProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
ins := NewInstaller(rootDir)
|
||||
|
||||
libFoo := &types.PkgInfo{Name: "libfoo", Version: "1.0.0", Files: []string{"usr/lib/libfoo.so"}}
|
||||
libBar := &types.PkgInfo{Name: "libbar", Version: "1.0.0", Files: []string{"usr/lib/libbar.so"}}
|
||||
app := &types.PkgInfo{
|
||||
Name: "app",
|
||||
Version: "1.0.0",
|
||||
Dependencies: []string{"libfoo | libbar"},
|
||||
Files: []string{"usr/bin/app"},
|
||||
}
|
||||
|
||||
if err := ins.registerPackage(libFoo); err != nil {
|
||||
t.Fatalf("registerPackage(libFoo) error = %v", err)
|
||||
}
|
||||
if err := ins.registerPackage(libBar); err != nil {
|
||||
t.Fatalf("registerPackage(libBar) error = %v", err)
|
||||
}
|
||||
if err := ins.registerPackage(app); err != nil {
|
||||
t.Fatalf("registerPackage(app) error = %v", err)
|
||||
}
|
||||
|
||||
removed, err := ins.RemoveMany([]string{"libfoo"}, RemoveOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveMany() error = %v", err)
|
||||
}
|
||||
if len(removed) != 1 || removed[0] != "libfoo" {
|
||||
t.Fatalf("unexpected removed list: %#v", removed)
|
||||
}
|
||||
if !ins.IsInstalled("app") {
|
||||
t.Fatalf("app should remain installed because libbar satisfies dependency")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOrphans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -259,3 +422,44 @@ func TestListOrphans(t *testing.T) {
|
|||
t.Fatalf("did not expect libfoo to be orphan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOrphansAlternativeDependencyMarksChosenProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootDir := t.TempDir()
|
||||
ins := NewInstaller(rootDir)
|
||||
|
||||
libFoo := &types.PkgInfo{Name: "libfoo", Version: "1.0.0"}
|
||||
libBar := &types.PkgInfo{Name: "libbar", Version: "1.0.0"}
|
||||
app := &types.PkgInfo{Name: "app", Version: "1.0.0", Dependencies: []string{"libfoo | libbar"}}
|
||||
|
||||
if err := ins.registerPackage(libFoo); err != nil {
|
||||
t.Fatalf("registerPackage(libFoo) error = %v", err)
|
||||
}
|
||||
if err := ins.registerPackage(libBar); err != nil {
|
||||
t.Fatalf("registerPackage(libBar) error = %v", err)
|
||||
}
|
||||
if err := ins.registerPackage(app); err != nil {
|
||||
t.Fatalf("registerPackage(app) error = %v", err)
|
||||
}
|
||||
|
||||
orphans, err := ins.ListOrphans()
|
||||
if err != nil {
|
||||
t.Fatalf("ListOrphans() error = %v", err)
|
||||
}
|
||||
|
||||
set := make(map[string]struct{}, len(orphans))
|
||||
for _, name := range orphans {
|
||||
set[name] = struct{}{}
|
||||
}
|
||||
|
||||
if _, ok := set["app"]; !ok {
|
||||
t.Fatalf("expected app to be orphan")
|
||||
}
|
||||
if _, ok := set["libbar"]; !ok {
|
||||
t.Fatalf("expected libbar to be orphan when libfoo is first satisfiable provider")
|
||||
}
|
||||
if _, ok := set["libfoo"]; ok {
|
||||
t.Fatalf("did not expect libfoo to be orphan")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"zsvo/pkg/deps"
|
||||
)
|
||||
|
||||
// Recipe represents a package recipe.
|
||||
|
|
@ -200,8 +202,8 @@ func ParseRecipeFromReader(r io.Reader) (*Recipe, error) {
|
|||
if dep == "" {
|
||||
return nil, fmt.Errorf("recipe deps cannot contain empty values")
|
||||
}
|
||||
if strings.ContainsAny(dep, "|<>= \t\n\r") {
|
||||
return nil, fmt.Errorf("unsupported dependency format %q: use plain package names", dep)
|
||||
if _, err := deps.ParseRequirement(dep); err != nil {
|
||||
return nil, fmt.Errorf("invalid recipe dependency %q: %w", dep, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ deps:
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseRecipeRejectsComplexDependencySyntax(t *testing.T) {
|
||||
func TestParseRecipeAcceptsComplexDependencySyntax(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := `
|
||||
|
|
@ -57,11 +57,15 @@ install:
|
|||
- make DESTDIR={{pkgdir}} install
|
||||
deps:
|
||||
- glibc>=2.39
|
||||
- liblua5.1-0 | libluajit-5.1-2
|
||||
`
|
||||
|
||||
_, err := ParseRecipeFromReader(strings.NewReader(input))
|
||||
if err == nil {
|
||||
t.Fatalf("expected parse error for unsupported dependency syntax")
|
||||
r, err := ParseRecipeFromReader(strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("expected complex dependencies to parse, got error: %v", err)
|
||||
}
|
||||
if len(r.Deps) != 2 {
|
||||
t.Fatalf("expected 2 deps, got %d", len(r.Deps))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
BIN
zsvo
BIN
zsvo
Binary file not shown.
Loading…
Reference in a new issue