пися попа говно член пенис параша залуууууууупа сиси говно
This commit is contained in:
parent
2c7c0f877b
commit
05eabf12a7
14 changed files with 1883 additions and 138 deletions
|
|
@ -56,6 +56,8 @@ install:
|
||||||
deps:
|
deps:
|
||||||
- glibc
|
- glibc
|
||||||
- readline
|
- readline
|
||||||
|
- "zlib>=1.2.13"
|
||||||
|
- "lua5.1 | luajit"
|
||||||
```
|
```
|
||||||
|
|
||||||
Debian upstream source mode:
|
Debian upstream source mode:
|
||||||
|
|
@ -71,10 +73,14 @@ source:
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
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.
|
- `{{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`).
|
- 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.
|
- `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`).
|
- Package metadata is stored in `.zsvo.yml` (not `.PKGINFO`).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
|
||||||
607
cmd/install.go
607
cmd/install.go
|
|
@ -3,8 +3,12 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"zsvo/pkg/builder"
|
"zsvo/pkg/builder"
|
||||||
|
|
@ -28,13 +32,14 @@ var InstallCmd = &cobra.Command{
|
||||||
workDir = "/tmp/pkg-work"
|
workDir = "/tmp/pkg-work"
|
||||||
}
|
}
|
||||||
autoSource, _ := cmd.Flags().GetBool("auto-source")
|
autoSource, _ := cmd.Flags().GetBool("auto-source")
|
||||||
|
autoBuildDeps, _ := cmd.Flags().GetBool("auto-build-deps")
|
||||||
|
|
||||||
installTargets := make([]string, 0, len(args))
|
installTargets := make([]string, 0, len(args))
|
||||||
var resolver *debian.Resolver
|
i := installer.NewInstaller(rootDir)
|
||||||
var b *builder.Builder
|
|
||||||
|
var session *autoBuildSession
|
||||||
if autoSource {
|
if autoSource {
|
||||||
resolver = debian.NewResolver()
|
session = newAutoBuildSession(workDir, autoBuildDeps)
|
||||||
b = builder.NewBuilder(workDir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, target := range args {
|
for _, target := range args {
|
||||||
|
|
@ -55,25 +60,13 @@ var InstallCmd = &cobra.Command{
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Resolving Debian source for %s...\n", target)
|
builtPackage, err := session.buildPackage(target, false, nil)
|
||||||
srcInfo, err := resolver.ResolveSource(target)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
installTargets = append(installTargets, builtPackage)
|
||||||
}
|
}
|
||||||
|
|
||||||
i := installer.NewInstaller(rootDir)
|
|
||||||
|
|
||||||
if len(installTargets) == 1 {
|
if len(installTargets) == 1 {
|
||||||
fmt.Printf("Installing package from %s...\n", installTargets[0])
|
fmt.Printf("Installing package from %s...\n", installTargets[0])
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -92,6 +85,7 @@ func init() {
|
||||||
InstallCmd.Flags().StringP("root", "r", "/", "Root directory for installation")
|
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().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-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) {
|
func isInstallFileTarget(target string) (bool, error) {
|
||||||
|
|
@ -130,6 +124,375 @@ func looksLikeFilePath(target string) bool {
|
||||||
strings.HasSuffix(target, ".zov")
|
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 {
|
func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe {
|
||||||
name := src.SourcePackage
|
name := src.SourcePackage
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|
@ -142,8 +505,8 @@ func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe {
|
||||||
|
|
||||||
installCmd := fmt.Sprintf(
|
installCmd := fmt.Sprintf(
|
||||||
"if [ -f build/cmake_install.cmake ]; then DESTDIR={{pkgdir}} cmake --install build; "+
|
"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 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 install; "+
|
"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",
|
"elif [ -f %s ]; then install -Dm755 %s {{pkgdir}}/usr/bin/%s; fi",
|
||||||
name,
|
name,
|
||||||
name,
|
name,
|
||||||
|
|
@ -159,11 +522,209 @@ func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe {
|
||||||
Sha256: src.DSCSHA256,
|
Sha256: src.DSCSHA256,
|
||||||
},
|
},
|
||||||
Build: []string{
|
Build: []string{
|
||||||
"[ -f configure ] && ./configure --prefix=/usr || true",
|
"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 && cmake --build build -j${jobs}; " +
|
"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 && meson compile -C build -j${jobs}; " +
|
"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}; fi",
|
"elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make -j${jobs} ${ZSVO_MAKE_FLAGS}; fi",
|
||||||
},
|
},
|
||||||
Install: []string{installCmd},
|
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
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"zsvo/pkg/debian"
|
"zsvo/pkg/debian"
|
||||||
|
|
@ -68,4 +71,59 @@ func TestAutoRecipeFromDebian(t *testing.T) {
|
||||||
if len(r.Build) == 0 || len(r.Install) == 0 {
|
if len(r.Build) == 0 || len(r.Install) == 0 {
|
||||||
t.Fatalf("expected auto recipe build/install commands")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
main.go
2
main.go
|
|
@ -11,6 +11,8 @@ import (
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "zsvo",
|
Use: "zsvo",
|
||||||
Short: "A simple source-based package manager",
|
Short: "A simple source-based package manager",
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
Long: `A minimal package manager for custom Linux distributions based on LFS
|
Long: `A minimal package manager for custom Linux distributions based on LFS
|
||||||
|
|
||||||
Available commands:
|
Available commands:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,16 @@ import (
|
||||||
// Builder handles building packages from recipes
|
// Builder handles building packages from recipes
|
||||||
type Builder struct {
|
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
|
// NewBuilder creates a new builder
|
||||||
|
|
@ -31,36 +41,51 @@ func (b *Builder) Build(recipe *recipe.Recipe) error {
|
||||||
return fmt.Errorf("recipe cannot be nil")
|
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
|
// Create working directories
|
||||||
sourceDir := recipe.GetSourceDir(b.workDir)
|
sourceDir := recipe.GetSourceDir(b.workDir)
|
||||||
stagingDir := recipe.GetStagingDir(b.workDir)
|
stagingDir := recipe.GetStagingDir(b.workDir)
|
||||||
packageDir := recipe.GetPackageDir(b.workDir)
|
packageDir := recipe.GetPackageDir(b.workDir)
|
||||||
|
|
||||||
|
nextStep("Preparing directories")
|
||||||
if err := b.prepareDirectories(sourceDir, stagingDir, packageDir); err != nil {
|
if err := b.prepareDirectories(sourceDir, stagingDir, packageDir); err != nil {
|
||||||
return fmt.Errorf("failed to prepare directories: %w", err)
|
return fmt.Errorf("failed to prepare directories: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download and extract source
|
// Download and extract source
|
||||||
|
nextStep("Downloading and extracting source")
|
||||||
if err := b.downloadAndExtract(recipe); err != nil {
|
if err := b.downloadAndExtract(recipe); err != nil {
|
||||||
return fmt.Errorf("failed to download and extract source: %w", err)
|
return fmt.Errorf("failed to download and extract source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply patches
|
// Apply patches
|
||||||
|
nextStep("Applying recipe patches")
|
||||||
if err := b.applyPatches(recipe, sourceDir); err != nil {
|
if err := b.applyPatches(recipe, sourceDir); err != nil {
|
||||||
return fmt.Errorf("failed to apply patches: %w", err)
|
return fmt.Errorf("failed to apply patches: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build package
|
// 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)
|
return fmt.Errorf("failed to build package: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Package files
|
// 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)
|
return fmt.Errorf("failed to package files: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create package archive
|
// Create package archive
|
||||||
|
nextStep("Creating package archive")
|
||||||
p := packager.NewPackager(b.workDir)
|
p := packager.NewPackager(b.workDir)
|
||||||
if err := p.Package(recipe); err != nil {
|
if err := p.Package(recipe); err != nil {
|
||||||
return fmt.Errorf("failed to create package archive: %w", err)
|
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
|
// 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)
|
// Find the source directory (usually the first subdirectory)
|
||||||
srcPath, err := b.findSourceDirectory(sourceDir)
|
srcPath, err := b.findSourceDirectory(sourceDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -145,6 +170,10 @@ func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir stri
|
||||||
|
|
||||||
// Execute build commands
|
// Execute build commands
|
||||||
for i, cmd := range recipe.Build {
|
for i, cmd := range recipe.Build {
|
||||||
|
if progressFn != nil {
|
||||||
|
progressFn(i+1, len(recipe.Build))
|
||||||
|
}
|
||||||
|
|
||||||
// Substitute variables in command
|
// Substitute variables in command
|
||||||
cmd = b.substituteVariables(cmd, sourceDir, stagingDir)
|
cmd = b.substituteVariables(cmd, sourceDir, stagingDir)
|
||||||
|
|
||||||
|
|
@ -192,7 +221,7 @@ func (b *Builder) buildEnvironment(stagingDir string) []string {
|
||||||
// Add parallel build variable
|
// Add parallel build variable
|
||||||
env = append(env, fmt.Sprintf("MAKEFLAGS=-j%d", runtime.NumCPU()))
|
env = append(env, fmt.Sprintf("MAKEFLAGS=-j%d", runtime.NumCPU()))
|
||||||
|
|
||||||
return env
|
return applyEnvOverrides(env, b.envOverrides)
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeCommand executes a single command
|
// 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 := exec.Command("sh", "-c", command)
|
||||||
cmd.Dir = workDir
|
cmd.Dir = workDir
|
||||||
cmd.Env = env
|
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.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
|
|
||||||
|
|
@ -308,6 +350,53 @@ func (b *Builder) SetWorkDir(workDir string) {
|
||||||
b.workDir = workDir
|
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
|
// substituteVariables substitutes variables in command strings
|
||||||
func (b *Builder) substituteVariables(cmdStr, sourceDir, stagingDir string) string {
|
func (b *Builder) substituteVariables(cmdStr, sourceDir, stagingDir string) string {
|
||||||
// Get number of CPU cores
|
// Get number of CPU cores
|
||||||
|
|
@ -325,7 +414,7 @@ func (b *Builder) substituteVariables(cmdStr, sourceDir, stagingDir string) stri
|
||||||
}
|
}
|
||||||
|
|
||||||
// packageFiles runs the package commands
|
// 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)
|
// Find the source directory (usually the first subdirectory)
|
||||||
srcPath, err := b.findSourceDirectory(sourceDir)
|
srcPath, err := b.findSourceDirectory(sourceDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -337,6 +426,10 @@ func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir stri
|
||||||
|
|
||||||
// Execute package commands
|
// Execute package commands
|
||||||
for i, cmd := range recipe.Install {
|
for i, cmd := range recipe.Install {
|
||||||
|
if progressFn != nil {
|
||||||
|
progressFn(i+1, len(recipe.Install))
|
||||||
|
}
|
||||||
|
|
||||||
// Substitute variables in command
|
// Substitute variables in command
|
||||||
cmd = b.substituteVariables(cmd, sourceDir, stagingDir)
|
cmd = b.substituteVariables(cmd, sourceDir, stagingDir)
|
||||||
|
|
||||||
|
|
@ -347,3 +440,53 @@ func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir stri
|
||||||
|
|
||||||
return nil
|
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
|
Directory string
|
||||||
DSCName string
|
DSCName string
|
||||||
DSCSHA256 string
|
DSCSHA256 string
|
||||||
|
Binaries []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Resolver) findPackageInIndex(mirror, suite, component, pkg string) (*sourceRecord, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
if rec.Package == pkg {
|
if rec.Package == pkg || containsSourceBinary(rec.Binaries, pkg) {
|
||||||
return &rec, true, nil
|
return &rec, true, nil
|
||||||
}
|
}
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
|
|
@ -294,9 +295,41 @@ func parseSourcesParagraph(lines []string) (sourceRecord, error) {
|
||||||
Directory: dir,
|
Directory: dir,
|
||||||
DSCName: dscName,
|
DSCName: dscName,
|
||||||
DSCSHA256: dscHash,
|
DSCSHA256: dscHash,
|
||||||
|
Binaries: parseCommaSeparatedField(fields["Binary"]),
|
||||||
}, nil
|
}, 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) {
|
func parseChecksumsForDSC(raw string) (string, string) {
|
||||||
for _, line := range strings.Split(raw, "\n") {
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
fields := strings.Fields(strings.TrimSpace(line))
|
fields := strings.Fields(strings.TrimSpace(line))
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ func TestResolveSourceHTTPFallbackToGzip(t *testing.T) {
|
||||||
|
|
||||||
sources := strings.TrimSpace(`
|
sources := strings.TrimSpace(`
|
||||||
Package: neofetch
|
Package: neofetch
|
||||||
|
Binary: neofetch
|
||||||
Version: 7.1.0-4
|
Version: 7.1.0-4
|
||||||
Directory: pool/main/n/neofetch
|
Directory: pool/main/n/neofetch
|
||||||
Checksums-Sha256:
|
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) {
|
func TestResolveSourceHTTPNotFound(t *testing.T) {
|
||||||
t.Parallel()
|
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
|
package installer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"container/heap"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -9,6 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"zsvo/pkg/deps"
|
||||||
"zsvo/pkg/packager"
|
"zsvo/pkg/packager"
|
||||||
"zsvo/pkg/types"
|
"zsvo/pkg/types"
|
||||||
)
|
)
|
||||||
|
|
@ -153,15 +155,12 @@ func (i *Installer) installManyNoLock(packagePaths []string) error {
|
||||||
defer os.RemoveAll(txDir)
|
defer os.RemoveAll(txDir)
|
||||||
|
|
||||||
applied := make([]installTransactionState, 0, len(candidates))
|
applied := make([]installTransactionState, 0, len(candidates))
|
||||||
remaining := append([]installCandidate(nil), candidates...)
|
ordered, err := solveInstallOrder(candidates, installedInfos)
|
||||||
|
if err != nil {
|
||||||
for len(remaining) > 0 {
|
return err
|
||||||
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)
|
state, err := i.installPackageTxNoLock(candidate.path, txDir, installedInfos)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if rbErr := i.rollbackInstallTransactionNoLock(applied); rbErr != nil {
|
if rbErr := i.rollbackInstallTransactionNoLock(applied); rbErr != nil {
|
||||||
|
|
@ -172,29 +171,6 @@ func (i *Installer) installManyNoLock(packagePaths []string) error {
|
||||||
|
|
||||||
applied = append(applied, *state)
|
applied = append(applied, *state)
|
||||||
installedInfos[candidate.info.Name] = candidate.info
|
installedInfos[candidate.info.Name] = candidate.info
|
||||||
remaining = append(remaining[:idx], remaining[idx+1:]...)
|
|
||||||
progress = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
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, ", "))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -844,47 +820,50 @@ func removePathIfExists(path string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSimpleDependencies(deps []string) error {
|
func validateSimpleDependencies(rawDeps []string) error {
|
||||||
for _, dep := range deps {
|
if _, err := deps.ParseRequirements(rawDeps); err != nil {
|
||||||
if depNameFromConstraint(dep) == "" {
|
return err
|
||||||
return fmt.Errorf("invalid dependency: %q", dep)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func depsSatisfied(deps []string, installedInfos map[string]*types.PkgInfo, selfName string) bool {
|
func depsSatisfied(rawDeps []string, installedInfos map[string]*types.PkgInfo, selfName string) bool {
|
||||||
for _, dep := range deps {
|
reqs, err := deps.ParseRequirements(rawDeps)
|
||||||
depName := depNameFromConstraint(dep)
|
if err != nil {
|
||||||
if depName == "" {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if depName == selfName {
|
|
||||||
continue
|
available := packageVersions(installedInfos)
|
||||||
}
|
for _, req := range reqs {
|
||||||
if _, ok := installedInfos[depName]; !ok {
|
if _, ok := resolveRequirementProvider(req, available, nil, selfName); !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
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)
|
missing := make([]string, 0)
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
for _, dep := range deps {
|
available := packageVersions(installedInfos)
|
||||||
depName := depNameFromConstraint(dep)
|
for _, req := range reqs {
|
||||||
if depName == "" || depName == selfName {
|
if _, ok := resolveRequirementProvider(req, available, nil, selfName); ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := installedInfos[depName]; ok {
|
label := req.Raw
|
||||||
|
if label == "" {
|
||||||
|
label = formatRequirement(req)
|
||||||
|
}
|
||||||
|
if _, exists := seen[label]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, exists := seen[depName]; exists {
|
seen[label] = struct{}{}
|
||||||
continue
|
missing = append(missing, label)
|
||||||
}
|
|
||||||
seen[depName] = struct{}{}
|
|
||||||
missing = append(missing, depName)
|
|
||||||
}
|
}
|
||||||
sort.Strings(missing)
|
sort.Strings(missing)
|
||||||
return missing
|
return missing
|
||||||
|
|
@ -901,27 +880,6 @@ func checkDependenciesAgainstInstalled(deps []string, installedInfos map[string]
|
||||||
return fmt.Errorf("dependency not installed: %s", strings.Join(missing, ", "))
|
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) {
|
func normalizePackageNames(packageNames []string) ([]string, error) {
|
||||||
if len(packageNames) == 0 {
|
if len(packageNames) == 0 {
|
||||||
return nil, fmt.Errorf("no packages provided")
|
return nil, fmt.Errorf("no packages provided")
|
||||||
|
|
@ -961,17 +919,15 @@ func brokenPackagesAfterRemoval(infos map[string]*types.PkgInfo, removeSet map[s
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
|
|
||||||
broken := make([]string, 0)
|
broken := make([]string, 0)
|
||||||
|
remainingVersions := packageVersions(remaining)
|
||||||
for _, pkgName := range names {
|
for _, pkgName := range names {
|
||||||
pkgInfo := remaining[pkgName]
|
pkgInfo := remaining[pkgName]
|
||||||
for _, dep := range pkgInfo.Dependencies {
|
reqs, err := deps.ParseRequirements(pkgInfo.Dependencies)
|
||||||
depName := depNameFromConstraint(dep)
|
if err != nil {
|
||||||
if depName == "" {
|
return nil, fmt.Errorf("invalid dependency in package %s: %w", pkgName, err)
|
||||||
return nil, fmt.Errorf("invalid dependency in package %s: %q", pkgName, dep)
|
|
||||||
}
|
}
|
||||||
if depName == pkgName {
|
for _, req := range reqs {
|
||||||
continue
|
if _, ok := resolveRequirementProvider(req, remainingVersions, nil, pkgName); !ok {
|
||||||
}
|
|
||||||
if _, ok := remaining[depName]; !ok {
|
|
||||||
broken = append(broken, pkgName)
|
broken = append(broken, pkgName)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -983,15 +939,18 @@ func brokenPackagesAfterRemoval(infos map[string]*types.PkgInfo, removeSet map[s
|
||||||
|
|
||||||
func listOrphansFromInfos(infos map[string]*types.PkgInfo) []string {
|
func listOrphansFromInfos(infos map[string]*types.PkgInfo) []string {
|
||||||
required := make(map[string]struct{})
|
required := make(map[string]struct{})
|
||||||
|
versions := packageVersions(infos)
|
||||||
for owner, info := range infos {
|
for owner, info := range infos {
|
||||||
for _, dep := range info.Dependencies {
|
reqs, err := deps.ParseRequirements(info.Dependencies)
|
||||||
depName := depNameFromConstraint(dep)
|
if err != nil {
|
||||||
if depName == "" || depName == owner {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := infos[depName]; ok {
|
for _, req := range reqs {
|
||||||
required[depName] = struct{}{}
|
provider, ok := resolveRequirementProvider(req, versions, nil, owner)
|
||||||
|
if !ok || provider == owner {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
required[provider] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1005,6 +964,191 @@ func listOrphansFromInfos(infos map[string]*types.PkgInfo) []string {
|
||||||
return orphans
|
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) {
|
func sanitizePackagePath(path string) (string, error) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return "", fmt.Errorf("file path cannot be empty")
|
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) {
|
func TestInstallRespectsRootFlag(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestListOrphans(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|
@ -259,3 +422,44 @@ func TestListOrphans(t *testing.T) {
|
||||||
t.Fatalf("did not expect libfoo to be orphan")
|
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"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"zsvo/pkg/deps"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Recipe represents a package recipe.
|
// Recipe represents a package recipe.
|
||||||
|
|
@ -200,8 +202,8 @@ func ParseRecipeFromReader(r io.Reader) (*Recipe, error) {
|
||||||
if dep == "" {
|
if dep == "" {
|
||||||
return nil, fmt.Errorf("recipe deps cannot contain empty values")
|
return nil, fmt.Errorf("recipe deps cannot contain empty values")
|
||||||
}
|
}
|
||||||
if strings.ContainsAny(dep, "|<>= \t\n\r") {
|
if _, err := deps.ParseRequirement(dep); err != nil {
|
||||||
return nil, fmt.Errorf("unsupported dependency format %q: use plain package names", dep)
|
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()
|
t.Parallel()
|
||||||
|
|
||||||
input := `
|
input := `
|
||||||
|
|
@ -57,11 +57,15 @@ install:
|
||||||
- make DESTDIR={{pkgdir}} install
|
- make DESTDIR={{pkgdir}} install
|
||||||
deps:
|
deps:
|
||||||
- glibc>=2.39
|
- glibc>=2.39
|
||||||
|
- liblua5.1-0 | libluajit-5.1-2
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := ParseRecipeFromReader(strings.NewReader(input))
|
r, err := ParseRecipeFromReader(strings.NewReader(input))
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatalf("expected parse error for unsupported dependency syntax")
|
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