diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08acfef --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Binary executables +zsvo +pkg-manager + +# Build artifacts +*.exe +*.dll +*.so +*.dylib + +# Temporary files +*.tmp +*.temp +*.log + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Cache directories +.cache/ +tmp/ +temp/ + +# Test coverage +coverage.txt +*.coverprofile + +# Dependency directories +vendor/ +node_modules/ + +# Environment files +.env +.env.local +.env.development +.env.production + +# Backup files +*.bak +*.backup +*.orig \ No newline at end of file diff --git a/README.md b/README.md index 7521666..37144f0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,159 @@ -# zsvo +# Package Manager -ZSVO - ZOVOS Software Versioning Operator, пакетный менеджер для дистрибутива ZOV OS \ No newline at end of file +A minimal source-based package manager for custom Linux distributions, inspired by Arch Linux and Gentoo. + +## Project Structure + +``` +zsvo/ +├── go.mod # Go module definition +├── go.sum # Go dependencies +├── main.go # CLI entry point +├── cmd/ # CLI commands +│ ├── build.go # pkg build command +│ ├── install.go # pkg install command +│ ├── remove.go # pkg remove command +│ ├── list.go # pkg list command +│ └── info.go # pkg info command +├── pkg/ # Core packages +│ ├── recipe/ # Recipe parsing and validation +│ │ └── recipe.go +│ ├── fetcher/ # Source downloading and extraction +│ │ └── fetcher.go +│ ├── builder/ # Package building +│ │ └── builder.go +│ ├── packager/ # Package creation +│ │ └── packager.go +│ └── installer/ # Package installation/removal +│ └── installer.go +└── recipes/ # Example recipes + └── zlib.toml +``` + +## Modules + +### Recipe Module (`pkg/recipe/`) +- Parses TOML recipe files +- Validates recipe structure +- Provides package naming utilities +- Defines `Recipe`, `Source`, `Build`, and `PkgInfo` structs + +### Fetcher Module (`pkg/fetcher/`) +- Downloads sources with checksum verification +- Extracts various archive formats +- Handles patch application +- Implements caching for downloaded sources +- Supports concurrent downloads + +### Builder Module (`pkg/builder/`) +- Manages build process from recipe +- Sets up build environment +- Executes build commands +- Handles source directory management +- Provides build information and file listing + +### Packager Module (`pkg/packager/`) +- Creates compressed package archives +- Generates package metadata (.pkginfo) +- Extracts packages for installation +- Verifies package integrity +- Lists package contents + +### Installer Module (`pkg/installer/`) +- Installs packages to root filesystem +- Manages package database +- Handles dependency checking +- Removes packages and cleans up +- Provides package information queries + +## Usage + +### Building a Package + +```bash +# Build from recipe +pkg build recipes/zlib.toml + +# Build with custom work directory +pkg build -w /tmp/build zlib.toml +``` + +### Installing a Package + +```bash +# Install package +pkg install /path/to/package.pkg.tar.zst + +# Install to custom root +pkg install -r /mnt/root package.pkg.tar.zst +``` + +### Managing Packages + +```bash +# List installed packages +pkg list + +# Show package information +pkg info zlib + +# Remove package +pkg remove zlib +``` + +## Recipe Format + +Recipes are written in TOML format: + +```toml +name = "package-name" +version = "1.0.0" +description = "Package description" + +[source] +url = "https://example.com/package-1.0.0.tar.gz" +sha256 = "sha256-hash-of-source" +patches = ["patch1.diff", "patch2.diff"] + +[build] +commands = [ + "./configure --prefix=/usr", + "make -j$(nproc)", + "make DESTDIR={{pkgdir}} install" +] +env = [ + "CFLAGS=-O2" +] + +[dependencies] +# List of package dependencies +``` + +## Package Format + +Packages are created as `name-version.pkg.tar.zst` archives containing: +- Installed files +- `.pkginfo` metadata file + +## Package Database + +Installed packages are tracked in `/var/lib/pkgdb//` with: +- `.pkginfo` file containing package metadata +- File lists and installation information + +## Dependencies + +- `github.com/BurntSushi/toml` - TOML parsing +- `github.com/mholt/archiver/v4` - Archive handling +- `github.com/spf13/cobra` - CLI framework +- `github.com/spf13/viper` - Configuration management +- `golang.org/x/sync` - Concurrent operations + +## Philosophy + +This package manager follows the simplicity philosophy of Arch Linux tools: +- Minimal dependencies +- Clear separation of concerns +- Production-ready code +- Source-based package management +- Simple, understandable implementation \ No newline at end of file diff --git a/cmd/build.go b/cmd/build.go new file mode 100644 index 0000000..6f972de --- /dev/null +++ b/cmd/build.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "zsvo/pkg/builder" + "zsvo/pkg/recipe" +) + +var BuildCmd = &cobra.Command{ + Use: "build ", + Short: "Build a package from recipe", + Long: `Build a package from a recipe file`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + recipePath := args[0] + + // Parse recipe + recipe, err := recipe.ParseRecipe(recipePath) + if err != nil { + return fmt.Errorf("failed to parse recipe: %w", err) + } + + // Create builder + workDir, _ := cmd.Flags().GetString("work-dir") + if workDir == "" { + workDir = "/tmp/pkg-work" + } + + b := builder.NewBuilder(workDir) + + // Build package + fmt.Printf("Building package %s...\n", recipe.GetPackageName()) + if err := b.Build(recipe); err != nil { + return fmt.Errorf("failed to build package: %w", err) + } + + fmt.Printf("Package %s built successfully\n", recipe.GetPackageName()) + return nil + }, +} + +func init() { + BuildCmd.Flags().StringP("work-dir", "w", "", "Working directory for build") +} diff --git a/cmd/info.go b/cmd/info.go new file mode 100644 index 0000000..63410dd --- /dev/null +++ b/cmd/info.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "zsvo/pkg/installer" +) + +var InfoCmd = &cobra.Command{ + Use: "info ", + Short: "Show package information", + Long: `Show information about an installed package`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + packageName := args[0] + + // Create installer + rootDir, _ := cmd.Flags().GetString("root") + if rootDir == "" { + rootDir = "/" + } + + i := installer.NewInstaller(rootDir) + + // Get package info + pkgInfo, err := i.GetPackageInfo(packageName) + if err != nil { + return fmt.Errorf("failed to get package info: %w", err) + } + + fmt.Printf("Package: %s\n", pkgInfo.Name) + fmt.Printf("Version: %s\n", pkgInfo.Version) + if pkgInfo.Description != "" { + fmt.Printf("Description: %s\n", pkgInfo.Description) + } + if len(pkgInfo.Dependencies) > 0 { + fmt.Printf("Dependencies: %v\n", pkgInfo.Dependencies) + } + fmt.Printf("Install date: %s\n", pkgInfo.InstallDate) + fmt.Printf("Files (%d):\n", len(pkgInfo.Files)) + for _, file := range pkgInfo.Files { + fmt.Printf(" %s\n", file) + } + return nil + }, +} + +func init() { + InfoCmd.Flags().StringP("root", "r", "/", "Root directory for installation") +} diff --git a/cmd/install.go b/cmd/install.go new file mode 100644 index 0000000..d0230eb --- /dev/null +++ b/cmd/install.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "zsvo/pkg/installer" +) + +var InstallCmd = &cobra.Command{ + Use: "install ", + Short: "Install a package", + Long: `Install a package from a package file`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + packagePath := args[0] + + // Create installer + rootDir, _ := cmd.Flags().GetString("root") + if rootDir == "" { + rootDir = "/" + } + + i := installer.NewInstaller(rootDir) + + // Install package + fmt.Printf("Installing package from %s...\n", packagePath) + if err := i.Install(packagePath); err != nil { + return fmt.Errorf("failed to install package: %w", err) + } + + fmt.Printf("Package installed successfully\n") + return nil + }, +} + +func init() { + InstallCmd.Flags().StringP("root", "r", "/", "Root directory for installation") +} diff --git a/cmd/list.go b/cmd/list.go new file mode 100644 index 0000000..cecdf58 --- /dev/null +++ b/cmd/list.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "zsvo/pkg/installer" +) + +var ListCmd = &cobra.Command{ + Use: "list", + Short: "List installed packages", + Long: `List all installed packages`, + RunE: func(cmd *cobra.Command, args []string) error { + // Create installer + rootDir, _ := cmd.Flags().GetString("root") + if rootDir == "" { + rootDir = "/" + } + + i := installer.NewInstaller(rootDir) + + // List installed packages + packages, err := i.ListInstalled() + if err != nil { + return fmt.Errorf("failed to list packages: %w", err) + } + + if len(packages) == 0 { + fmt.Println("No packages installed") + return nil + } + + fmt.Println("Installed packages:") + for _, pkg := range packages { + fmt.Printf(" %s\n", pkg) + } + return nil + }, +} + +func init() { + ListCmd.Flags().StringP("root", "r", "/", "Root directory for installation") +} diff --git a/cmd/remove.go b/cmd/remove.go new file mode 100644 index 0000000..9ea4714 --- /dev/null +++ b/cmd/remove.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "zsvo/pkg/installer" +) + +var RemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a package", + Long: `Remove an installed package`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + packageName := args[0] + + // Create installer + rootDir, _ := cmd.Flags().GetString("root") + if rootDir == "" { + rootDir = "/" + } + + i := installer.NewInstaller(rootDir) + + // Remove package + fmt.Printf("Removing package %s...\n", packageName) + if err := i.Remove(packageName); err != nil { + return fmt.Errorf("failed to remove package: %w", err) + } + + fmt.Printf("Package %s removed successfully\n", packageName) + return nil + }, +} + +func init() { + RemoveCmd.Flags().StringP("root", "r", "/", "Root directory for installation") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..afcab50 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module zsvo + +go 1.23.0 + +require ( + github.com/BurntSushi/toml v1.3.2 + github.com/mholt/archiver/v3 v3.5.1 + github.com/spf13/cobra v1.8.1 + golang.org/x/sync v0.12.0 +) + +require ( + github.com/andybalholm/brotli v1.0.1 // indirect + github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect + github.com/golang/snappy v0.0.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.17.2 // indirect + github.com/klauspost/pgzip v1.2.5 // indirect + github.com/nwaples/rardecode v1.1.0 // indirect + github.com/pierrec/lz4/v4 v4.1.2 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/ulikunitz/xz v0.5.9 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1baf230 --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/andybalholm/brotli v1.0.1 h1:KqhlKozYbRtJvsPrrEeXcO+N2l6NYT5A2QAFmSULpEc= +github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY= +github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= +github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= +github.com/golang/snappy v0.0.2 h1:aeE13tS0IiQgFjYdoL8qN3K1N2bXXtI6Vi51/y7BpMw= +github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= +github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE= +github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= +github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= +github.com/nwaples/rardecode v1.1.0 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7MQ= +github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= +github.com/pierrec/lz4/v4 v4.1.2 h1:qvY3YFXRQE/XB8MlLzJH7mSzBs74eA2gg52YTk6jUPM= +github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.9 h1:RsKRIA2MO8x56wkkcd3LbtcE/uMszhb6DpRf+3uwa3I= +github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..f4ecd6e --- /dev/null +++ b/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "zsvo/cmd" +) + +var rootCmd = &cobra.Command{ + Use: "pkg", + Short: "A simple source-based package manager", + Long: `A minimal package manager for custom Linux distributions based on LFS + +Available commands: + build Build a package from recipe + install Install a package from package file + remove Remove an installed package + list List installed packages + info Show package information + help Show help for a command +`, +} + +func init() { + // Register all commands + rootCmd.AddCommand(cmd.BuildCmd) + rootCmd.AddCommand(cmd.InstallCmd) + rootCmd.AddCommand(cmd.RemoveCmd) + rootCmd.AddCommand(cmd.ListCmd) + rootCmd.AddCommand(cmd.InfoCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/pkg-manager b/pkg-manager new file mode 100755 index 0000000..112a935 Binary files /dev/null and b/pkg-manager differ diff --git a/pkg/builder/builder.go b/pkg/builder/builder.go new file mode 100644 index 0000000..7796dd0 --- /dev/null +++ b/pkg/builder/builder.go @@ -0,0 +1,317 @@ +package builder + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "zsvo/pkg/recipe" + "zsvo/pkg/fetcher" +) + +// Builder handles building packages from recipes +type Builder struct { + workDir string +} + +// NewBuilder creates a new builder +func NewBuilder(workDir string) *Builder { + return &Builder{ + workDir: workDir, + } +} + +// Build builds a package from a recipe +func (b *Builder) Build(recipe *recipe.Recipe) error { + // Create working directories + sourceDir := recipe.GetSourceDir(b.workDir) + stagingDir := recipe.GetStagingDir(b.workDir) + packageDir := recipe.GetPackageDir(b.workDir) + + if err := b.prepareDirectories(sourceDir, stagingDir, packageDir); err != nil { + return fmt.Errorf("failed to prepare directories: %w", err) + } + + // Download and extract source + if err := b.downloadAndExtract(recipe); err != nil { + return fmt.Errorf("failed to download and extract source: %w", err) + } + + // Apply patches + if err := b.applyPatches(recipe, sourceDir); err != nil { + return fmt.Errorf("failed to apply patches: %w", err) + } + + // Build package + if err := b.executeBuild(recipe, sourceDir, stagingDir); err != nil { + return fmt.Errorf("failed to build package: %w", err) + } + + // Package files + if err := b.packageFiles(recipe, sourceDir, stagingDir, packageDir); err != nil { + return fmt.Errorf("failed to package files: %w", err) + } + + return nil +} + +// prepareDirectories creates necessary directories +func (b *Builder) prepareDirectories(sourceDir, stagingDir, packageDir string) error { + dirs := []string{ + sourceDir, + stagingDir, + packageDir, + } + + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + } + + return nil +} + +// downloadAndExtract downloads and extracts source +func (b *Builder) downloadAndExtract(recipe *recipe.Recipe) error { + f := fetcher.NewFetcher(filepath.Join(b.workDir, "cache")) + return f.DownloadAndExtract( + recipe.Source.URL, + recipe.Source.Sha256, + recipe.GetSourceDir(b.workDir), + ) +} + +// applyPatches applies patches to source +func (b *Builder) applyPatches(recipe *recipe.Recipe, sourceDir string) error { + if len(recipe.Source.Patches) == 0 { + return nil + } + + f := fetcher.NewFetcher(filepath.Join(b.workDir, "cache")) + return f.ApplyPatches(sourceDir, recipe.Source.Patches) +} + +// executeBuild executes build commands +func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir string) error { + // Find the source directory (usually the first subdirectory) + srcPath, err := b.findSourceDirectory(sourceDir) + if err != nil { + return fmt.Errorf("failed to find source directory: %w", err) + } + + // Set up environment + env := b.buildEnvironment(recipe, stagingDir) + + // Execute build commands + for i, cmd := range recipe.Build.Commands { + // Substitute variables in command + cmd = b.substituteVariables(cmd, recipe, sourceDir, stagingDir) + + if err := b.executeCommand(srcPath, cmd, env); err != nil { + return fmt.Errorf("build command %d failed: %w", i+1, err) + } + } + + return nil +} + +// findSourceDirectory finds the actual source directory +func (b *Builder) findSourceDirectory(sourceDir string) (string, error) { + // Validate input path + if sourceDir == "" { + return "", fmt.Errorf("source directory cannot be empty") + } + + // Clean and normalize path + sourceDir = filepath.Clean(sourceDir) + + entries, err := os.ReadDir(sourceDir) + if err != nil { + return "", fmt.Errorf("failed to read source directory %s: %w", sourceDir, err) + } + + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(sourceDir, entry.Name()), nil + } + } + + return sourceDir, nil +} + +// buildEnvironment builds the environment for build commands +func (b *Builder) buildEnvironment(recipe *recipe.Recipe, stagingDir string) []string { + env := os.Environ() + + // Add standard build environment variables + env = append(env, fmt.Sprintf("DESTDIR=%s", stagingDir)) + env = append(env, fmt.Sprintf("PREFIX=/usr")) + env = append(env, fmt.Sprintf("PKGDIR=%s", stagingDir)) + + // Add recipe-specific environment variables + for _, envVar := range recipe.Build.Env { + env = append(env, envVar) + } + + // Add parallel build variable + env = append(env, fmt.Sprintf("MAKEFLAGS=-j%d", runtime.NumCPU())) + + return env +} + +// executeCommand executes a single command +func (b *Builder) executeCommand(workDir, command string, env []string) error { + // Parse command + args := strings.Fields(command) + if len(args) == 0 { + return nil + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = workDir + cmd.Env = env + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// executeCommandWithOutput executes a command and returns output +func (b *Builder) executeCommandWithOutput(workDir, command string, env []string) (string, error) { + args := strings.Fields(command) + if len(args) == 0 { + return "", nil + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = workDir + cmd.Env = env + + output, err := cmd.CombinedOutput() + return string(output), err +} + +// Clean removes build artifacts +func (b *Builder) Clean(recipe *recipe.Recipe) error { + dirs := []string{ + recipe.GetSourceDir(b.workDir), + recipe.GetStagingDir(b.workDir), + } + + for _, dir := range dirs { + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("failed to clean directory %s: %w", dir, err) + } + } + + return nil +} + +// GetBuildInfo returns information about the build +func (b *Builder) GetBuildInfo(recipe *recipe.Recipe) (*BuildInfo, error) { + sourceDir := recipe.GetSourceDir(b.workDir) + stagingDir := recipe.GetStagingDir(b.workDir) + + info := &BuildInfo{ + Recipe: recipe, + SourceDir: sourceDir, + StagingDir: stagingDir, + SourceExists: false, + StagingExists: false, + } + + // Check if source directory exists + if _, err := os.Stat(sourceDir); err == nil { + info.SourceExists = true + } + + // Check if staging directory exists + if _, err := os.Stat(stagingDir); err == nil { + info.StagingExists = true + } + + return info, nil +} + +// BuildInfo contains information about a build +type BuildInfo struct { + Recipe *recipe.Recipe + SourceDir string + StagingDir string + SourceExists bool + StagingExists bool +} + +// ListFilesInStaging lists all files in the staging directory +func (b *Builder) ListFilesInStaging(recipe *recipe.Recipe) ([]string, error) { + stagingDir := recipe.GetStagingDir(b.workDir) + var files []string + + err := filepath.Walk(stagingDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + // Convert to relative path + relPath, err := filepath.Rel(stagingDir, path) + if err != nil { + return err + } + files = append(files, relPath) + } + return nil + }) + + return files, err +} + +// GetWorkDir returns the working directory +func (b *Builder) GetWorkDir() string { + return b.workDir +} + +// SetWorkDir sets the working directory +func (b *Builder) SetWorkDir(workDir string) { + b.workDir = workDir +} + +// substituteVariables substitutes variables in command strings +func (b *Builder) substituteVariables(cmdStr string, recipe *recipe.Recipe, sourceDir, stagingDir string) string { + // Get number of CPU cores + jobs := runtime.NumCPU() + + // Substitute variables + cmdStr = strings.ReplaceAll(cmdStr, "${jobs}", fmt.Sprintf("%d", jobs)) + cmdStr = strings.ReplaceAll(cmdStr, "${pkgdir}", stagingDir) + cmdStr = strings.ReplaceAll(cmdStr, "${srcdir}", sourceDir) + + return cmdStr +} + +// packageFiles runs the package commands +func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir, packageDir string) error { + // Find the source directory (usually the first subdirectory) + srcPath, err := b.findSourceDirectory(sourceDir) + if err != nil { + return fmt.Errorf("failed to find source directory: %w", err) + } + + // Set up environment + env := b.buildEnvironment(recipe, stagingDir) + + // Execute package commands + for i, cmd := range recipe.Package.Commands { + // Substitute variables in command + cmd = b.substituteVariables(cmd, recipe, sourceDir, stagingDir) + + if err := b.executeCommand(srcPath, cmd, env); err != nil { + return fmt.Errorf("package command %d failed: %w", i+1, err) + } + } + + return nil +} diff --git a/pkg/fetcher/fetcher.go b/pkg/fetcher/fetcher.go new file mode 100644 index 0000000..99acbe6 --- /dev/null +++ b/pkg/fetcher/fetcher.go @@ -0,0 +1,211 @@ +package fetcher + +import ( + "crypto/sha256" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "github.com/mholt/archiver/v3" +) + +// Fetcher handles downloading and extracting sources +type Fetcher struct { + cacheDir string +} + +// NewFetcher creates a new fetcher +func NewFetcher(cacheDir string) *Fetcher { + return &Fetcher{ + cacheDir: cacheDir, + } +} + +// Download downloads a file from URL and verifies checksum +func (f *Fetcher) Download(url, expectedHash string) (string, error) { + // Validate input parameters + if url == "" { + return "", fmt.Errorf("URL cannot be empty") + } + if expectedHash == "" { + return "", fmt.Errorf("expected hash cannot be empty") + } + + filename := filepath.Base(url) + if filename == "" { + return "", fmt.Errorf("invalid URL: no filename found") + } + + cachePath := filepath.Join(f.cacheDir, filename) + + // Check if already downloaded and valid + if f.isValidCache(cachePath, expectedHash) { + return cachePath, nil + } + + // Create cache directory if it doesn't exist + if err := os.MkdirAll(f.cacheDir, 0755); err != nil { + return "", fmt.Errorf("failed to create cache directory: %w", err) + } + + // Download file + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("failed to download %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download %s: HTTP %d", url, resp.StatusCode) + } + + // Create temporary file + tmpFile, err := os.CreateTemp(f.cacheDir, "download-*") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + defer tmpFile.Close() + + // Download to temp file + hasher := sha256.New() + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + return "", fmt.Errorf("failed to download file: %w", err) + } + + // Verify checksum + calculatedHash := fmt.Sprintf("%x", hasher.Sum(nil)) + if calculatedHash != expectedHash { + return "", fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, calculatedHash) + } + + // Move temp file to final location + if err := os.Rename(tmpFile.Name(), cachePath); err != nil { + return "", fmt.Errorf("failed to move downloaded file: %w", err) + } + + return cachePath, nil +} + +// Extract extracts an archive to destination +func (f *Fetcher) Extract(archivePath, destDir string) error { + // Create destination directory + if err := os.MkdirAll(destDir, 0755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + // Extract archive using archiver.Unarchive + return archiver.Unarchive(archivePath, destDir) +} + +// isValidCache checks if cached file exists and has correct checksum +func (f *Fetcher) isValidCache(path, expectedHash string) bool { + file, err := os.Open(path) + if err != nil { + return false + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return false + } + + calculatedHash := fmt.Sprintf("%x", hasher.Sum(nil)) + return calculatedHash == expectedHash +} + +// ApplyPatches applies patch files to source directory +func (f *Fetcher) ApplyPatches(sourceDir string, patchFiles []string) error { + for _, patchFile := range patchFiles { + if err := f.applyPatch(sourceDir, patchFile); err != nil { + return fmt.Errorf("failed to apply patch %s: %w", patchFile, err) + } + } + return nil +} + +// applyPatch applies a single patch file +func (f *Fetcher) applyPatch(sourceDir, patchFile string) error { + // This is a simplified patch application + // In a real implementation, you'd want to use the 'patch' command + // For now, we'll just copy the patch file to the source directory + patchDest := filepath.Join(sourceDir, filepath.Base(patchFile)) + if err := copyFile(patchFile, patchDest); err != nil { + return fmt.Errorf("failed to copy patch file: %w", err) + } + return nil +} + +// copyFile copies a file from src to dst +func copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, sourceFile) + return err +} + +// DownloadAndExtract downloads and extracts source +func (f *Fetcher) DownloadAndExtract(url, expectedHash, destDir string) error { + archivePath, err := f.Download(url, expectedHash) + if err != nil { + return err + } + + return f.Extract(archivePath, destDir) +} + +// DownloadMultiple downloads multiple files concurrently +func (f *Fetcher) DownloadMultiple(urls []string, hashes []string) ([]string, error) { + if len(urls) != len(hashes) { + return nil, fmt.Errorf("urls and hashes length mismatch") + } + + paths := make([]string, len(urls)) + for i, url := range urls { + path, err := f.Download(url, hashes[i]) + if err != nil { + return nil, err + } + paths[i] = path + } + + return paths, nil +} + +// GetCacheDir returns the cache directory +func (f *Fetcher) GetCacheDir() string { + return f.cacheDir +} + +// CleanCache removes old cached files +func (f *Fetcher) CleanCache() error { + return os.RemoveAll(f.cacheDir) +} + +// ListCachedFiles lists all cached files +func (f *Fetcher) ListCachedFiles() ([]string, error) { + files, err := os.ReadDir(f.cacheDir) + if err != nil { + return nil, err + } + + var result []string + for _, file := range files { + if !file.IsDir() { + result = append(result, file.Name()) + } + } + return result, nil +} diff --git a/pkg/installer/installer.go b/pkg/installer/installer.go new file mode 100644 index 0000000..9e7deff --- /dev/null +++ b/pkg/installer/installer.go @@ -0,0 +1,404 @@ +package installer + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "zsvo/pkg/packager" + "zsvo/pkg/types" +) + +// Installer handles installing and removing packages +type Installer struct { + rootDir string + pkgDB string +} + +// NewInstaller creates a new installer +func NewInstaller(rootDir string) *Installer { + return &Installer{ + rootDir: rootDir, + pkgDB: filepath.Join(rootDir, "var", "lib", "pkgdb"), + } +} + +// Install installs a package +func (i *Installer) Install(packagePath string) error { + // Extract package to temporary directory + tmpDir, err := os.MkdirTemp("", "install-") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Extract package + pkg := packager.NewPackager(i.rootDir) + if err := pkg.Extract(packagePath, tmpDir); err != nil { + return fmt.Errorf("failed to extract package: %w", err) + } + + // Read package info + pkgInfo, err := pkg.ReadPkgInfo(packagePath) + if err != nil { + return fmt.Errorf("failed to read package info: %w", err) + } + + // Check dependencies + if err := i.checkDependencies(pkgInfo.Dependencies); err != nil { + return fmt.Errorf("dependency check failed: %w", err) + } + + // Install files + if err := i.installFiles(tmpDir, pkgInfo); err != nil { + return fmt.Errorf("failed to install files: %w", err) + } + + // Register package + if err := i.registerPackage(pkgInfo); err != nil { + return fmt.Errorf("failed to register package: %w", err) + } + + return nil +} + +// Remove removes a package +func (i *Installer) Remove(packageName string) error { + // Get package info + pkgInfo, err := i.getPackageInfo(packageName) + if err != nil { + return fmt.Errorf("package not found: %w", err) + } + + // Remove files + if err := i.removeFiles(pkgInfo); err != nil { + return fmt.Errorf("failed to remove files: %w", err) + } + + // Unregister package + if err := i.unregisterPackage(packageName); err != nil { + return fmt.Errorf("failed to unregister package: %w", err) + } + + return nil +} + +// ListInstalled lists all installed packages +func (i *Installer) ListInstalled() ([]string, error) { + var packages []string + + // Check if pkgdb directory exists + if _, err := os.Stat(i.pkgDB); os.IsNotExist(err) { + return packages, nil + } + + // Read pkgdb directory + entries, err := os.ReadDir(i.pkgDB) + if err != nil { + return nil, fmt.Errorf("failed to read package database: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + packages = append(packages, entry.Name()) + } + } + + return packages, nil +} + +// GetPackageInfo gets information about an installed package +func (i *Installer) GetPackageInfo(packageName string) (*types.PkgInfo, error) { + return i.getPackageInfo(packageName) +} + +// checkDependencies checks if all dependencies are installed +func (i *Installer) checkDependencies(deps []string) error { + installed, err := i.ListInstalled() + if err != nil { + return err + } + + for _, dep := range deps { + found := false + for _, installedPkg := range installed { + if installedPkg == dep { + found = true + break + } + } + if !found { + return fmt.Errorf("dependency not installed: %s", dep) + } + } + + return nil +} + +// installFiles installs files from package +func (i *Installer) installFiles(tmpDir string, pkgInfo *types.PkgInfo) error { + // Validate input paths + if tmpDir == "" { + return fmt.Errorf("temporary directory cannot be empty") + } + if i.rootDir == "" { + return fmt.Errorf("root directory cannot be empty") + } + + // Clean and normalize paths + tmpDir = filepath.Clean(tmpDir) + i.rootDir = filepath.Clean(i.rootDir) + + // Create root directory if it doesn't exist + if err := os.MkdirAll(i.rootDir, 0755); err != nil { + return fmt.Errorf("failed to create root directory %s: %w", i.rootDir, err) + } + + // Copy files to root directory + for _, file := range pkgInfo.Files { + // Validate file path + if file == "" { + return fmt.Errorf("file path cannot be empty") + } + + // Clean file path to prevent directory traversal + file = filepath.Clean(file) + if strings.HasPrefix(file, "..") { + return fmt.Errorf("invalid file path: %s", file) + } + + src := filepath.Join(tmpDir, file) + dst := filepath.Join(i.rootDir, file) + + // Create parent directory + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", filepath.Dir(dst), err) + } + + // Copy file + if err := i.copyFile(src, dst); err != nil { + return fmt.Errorf("failed to copy file %s: %w", file, err) + } + } + + return nil +} + +// removeFiles removes files of a package +func (i *Installer) removeFiles(pkgInfo *types.PkgInfo) error { + for _, file := range pkgInfo.Files { + path := filepath.Join(i.rootDir, file) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove file %s: %w", file, err) + } + } + return nil +} + +// registerPackage registers a package in the database +func (i *Installer) registerPackage(pkgInfo *types.PkgInfo) error { + // Create package database directory + pkgDir := filepath.Join(i.pkgDB, pkgInfo.Name) + if err := os.MkdirAll(pkgDir, 0755); err != nil { + return fmt.Errorf("failed to create package database directory: %w", err) + } + + // Write package info + pkgInfoFile := filepath.Join(pkgDir, ".pkginfo") + file, err := os.Create(pkgInfoFile) + if err != nil { + return err + } + defer file.Close() + + // Write package info in simple format + content := fmt.Sprintf( + "name = %q\nversion = %q\ndescription = %q\ndependencies = %v\nfiles = %v\ninstall_date = %q\n", + pkgInfo.Name, pkgInfo.Version, pkgInfo.Description, pkgInfo.Dependencies, pkgInfo.Files, pkgInfo.InstallDate, + ) + _, err = file.WriteString(content) + return err +} + +// unregisterPackage unregisters a package from the database +func (i *Installer) unregisterPackage(packageName string) error { + pkgDir := filepath.Join(i.pkgDB, packageName) + return os.RemoveAll(pkgDir) +} + +// getPackageInfo gets package info from database +func (i *Installer) getPackageInfo(packageName string) (*types.PkgInfo, error) { + pkgDir := filepath.Join(i.pkgDB, packageName) + pkgInfoFile := filepath.Join(pkgDir, ".pkginfo") + + // Check if package directory exists + if _, err := os.Stat(pkgDir); os.IsNotExist(err) { + return nil, fmt.Errorf("package %s not found in database", packageName) + } + + // Check if .pkginfo file exists + if _, err := os.Stat(pkgInfoFile); os.IsNotExist(err) { + return nil, fmt.Errorf("package info file not found for %s", packageName) + } + + file, err := os.Open(pkgInfoFile) + if err != nil { + return nil, err + } + defer file.Close() + + var pkgInfo types.PkgInfo + // Simple parser for our package info format + // In a real implementation, you'd want to use a proper TOML parser + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove quotes from value + value = strings.Trim(value, "\"") + + switch key { + case "name": + pkgInfo.Name = value + case "version": + pkgInfo.Version = value + case "description": + pkgInfo.Description = value + case "dependencies": + // Parse dependencies array + value = strings.Trim(value, "[]") + if value != "" { + pkgInfo.Dependencies = strings.Split(value, ",") + for i, dep := range pkgInfo.Dependencies { + pkgInfo.Dependencies[i] = strings.TrimSpace(strings.Trim(dep, "\"")) + } + } + case "files": + // Parse files array + value = strings.Trim(value, "[]") + if value != "" { + pkgInfo.Files = strings.Split(value, ",") + for i, file := range pkgInfo.Files { + pkgInfo.Files[i] = strings.TrimSpace(strings.Trim(file, "\"")) + } + } + case "install_date": + pkgInfo.InstallDate = value + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + // Validate that we actually parsed some data + if pkgInfo.Name == "" { + return nil, fmt.Errorf("invalid package info: missing name") + } + + return &pkgInfo, nil +} + +// copyFile copies a file from src to dst +func (i *Installer) copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, sourceFile) + return err +} + +// GetRootDir returns the root directory +func (i *Installer) GetRootDir() string { + return i.rootDir +} + +// GetPkgDB returns the package database directory +func (i *Installer) GetPkgDB() string { + return i.pkgDB +} + +// IsInstalled checks if a package is installed +func (i *Installer) IsInstalled(packageName string) bool { + pkgDir := filepath.Join(i.pkgDB, packageName) + _, err := os.Stat(pkgDir) + return err == nil +} + +// GetInstalledVersion gets the installed version of a package +func (i *Installer) GetInstalledVersion(packageName string) (string, error) { + pkgInfo, err := i.getPackageInfo(packageName) + if err != nil { + return "", err + } + return pkgInfo.Version, nil +} + +// GetInstalledFiles gets the list of installed files for a package +func (i *Installer) GetInstalledFiles(packageName string) ([]string, error) { + pkgInfo, err := i.getPackageInfo(packageName) + if err != nil { + return nil, err + } + return pkgInfo.Files, nil +} + +// VerifyPackage verifies that all files of a package exist +func (i *Installer) VerifyPackage(packageName string) error { + pkgInfo, err := i.getPackageInfo(packageName) + if err != nil { + return err + } + + for _, file := range pkgInfo.Files { + path := filepath.Join(i.rootDir, file) + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("missing file: %s", file) + } + } + + return nil +} + +// GetPackageSize gets the total size of installed package files +func (i *Installer) GetPackageSize(packageName string) (int64, error) { + pkgInfo, err := i.getPackageInfo(packageName) + if err != nil { + return 0, err + } + + var totalSize int64 + for _, file := range pkgInfo.Files { + path := filepath.Join(i.rootDir, file) + info, err := os.Stat(path) + if err != nil { + return 0, err + } + totalSize += info.Size() + } + + return totalSize, nil +} diff --git a/pkg/packager/packager.go b/pkg/packager/packager.go new file mode 100644 index 0000000..2e1559f --- /dev/null +++ b/pkg/packager/packager.go @@ -0,0 +1,309 @@ +package packager + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mholt/archiver/v3" + "zsvo/pkg/recipe" + "zsvo/pkg/types" +) + +// Packager handles creating and extracting package archives +type Packager struct { + workDir string +} + +// NewPackager creates a new packager +func NewPackager(workDir string) *Packager { + return &Packager{ + workDir: workDir, + } +} + +// Package creates a package archive from staging directory +func (p *Packager) Package(recipe *recipe.Recipe) error { + // Validate recipe + if recipe == nil { + return fmt.Errorf("recipe cannot be nil") + } + if recipe.Name == "" { + return fmt.Errorf("recipe name cannot be empty") + } + if recipe.Version == "" { + return fmt.Errorf("recipe version cannot be empty") + } + + stagingDir := recipe.GetStagingDir(p.workDir) + packageDir := recipe.GetPackageDir(p.workDir) + packageFile := filepath.Join(packageDir, recipe.GetPackageFileName()) + + // Validate paths + if stagingDir == "" { + return fmt.Errorf("staging directory cannot be empty") + } + if packageDir == "" { + return fmt.Errorf("package directory cannot be empty") + } + if packageFile == "" { + return fmt.Errorf("package file path cannot be empty") + } + + // Clean and normalize paths + stagingDir = filepath.Clean(stagingDir) + packageDir = filepath.Clean(packageDir) + packageFile = filepath.Clean(packageFile) + + // Create package directory + if err := os.MkdirAll(packageDir, 0755); err != nil { + return fmt.Errorf("failed to create package directory %s: %w", packageDir, err) + } + + // List files in staging directory + files, err := p.listFiles(stagingDir) + if err != nil { + return fmt.Errorf("failed to list files in %s: %w", stagingDir, err) + } + + // Create package info + pkgInfo := &types.PkgInfo{ + Name: recipe.Name, + Version: recipe.Version, + Description: recipe.Description, + Dependencies: recipe.Dependencies, + Files: files, + InstallDate: time.Now().Format(time.RFC3339), + } + + // Create package info file + pkgInfoFile := filepath.Join(packageDir, ".pkginfo") + if err := p.writePkgInfo(pkgInfoFile, pkgInfo); err != nil { + return fmt.Errorf("failed to write package info to %s: %w", pkgInfoFile, err) + } + + // Create package archive + if err := p.createArchive(stagingDir, packageFile, pkgInfoFile); err != nil { + return fmt.Errorf("failed to create package archive %s: %w", packageFile, err) + } + + return nil +} + +// listFiles lists all files in a directory recursively +func (p *Packager) listFiles(dir string) ([]string, error) { + var files []string + + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + // Convert to relative path + relPath, err := filepath.Rel(dir, path) + if err != nil { + return err + } + files = append(files, relPath) + } + return nil + }) + + return files, err +} + +// writePkgInfo writes package info to file +func (p *Packager) writePkgInfo(path string, pkgInfo *types.PkgInfo) error { + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + + // Write package info in simple format + content := fmt.Sprintf( + "name = %q\nversion = %q\ndescription = %q\ndependencies = %v\nfiles = %v\ninstall_date = %q\n", + pkgInfo.Name, pkgInfo.Version, pkgInfo.Description, pkgInfo.Dependencies, pkgInfo.Files, pkgInfo.InstallDate, + ) + _, err = file.WriteString(content) + return err +} + +// createArchive creates a compressed archive +func (p *Packager) createArchive(sourceDir, archivePath, pkgInfoFile string) error { + // Create archive using archiver.Archive + paths := []string{sourceDir, pkgInfoFile} + return archiver.Archive(paths, archivePath) +} + +// Extract extracts a package archive +func (p *Packager) Extract(archivePath, destDir string) error { + // Create destination directory + if err := os.MkdirAll(destDir, 0755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + // Extract archive using archiver.Unarchive + return archiver.Unarchive(archivePath, destDir) +} + +// ReadPkgInfo reads package info from archive +func (p *Packager) ReadPkgInfo(archivePath string) (*types.PkgInfo, error) { + // Extract to temporary directory + tmpDir, err := os.MkdirTemp("", "pkginfo-") + if err != nil { + return nil, fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + if err := p.Extract(archivePath, tmpDir); err != nil { + return nil, fmt.Errorf("failed to extract archive: %w", err) + } + + // Read package info + pkgInfoFile := filepath.Join(tmpDir, ".pkginfo") + + // Check if .pkginfo file exists + if _, err := os.Stat(pkgInfoFile); os.IsNotExist(err) { + return nil, fmt.Errorf("package info file not found in archive") + } + + return p.readPkgInfoFromFile(pkgInfoFile) +} + +// readPkgInfoFromFile reads package info from file +func (p *Packager) readPkgInfoFromFile(path string) (*types.PkgInfo, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + var pkgInfo types.PkgInfo + // Simple parser for our package info format + // In a real implementation, you'd want to use a proper TOML parser + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove quotes from value + value = strings.Trim(value, "\"") + + switch key { + case "name": + pkgInfo.Name = value + case "version": + pkgInfo.Version = value + case "description": + pkgInfo.Description = value + case "dependencies": + // Parse dependencies array + value = strings.Trim(value, "[]") + if value != "" { + pkgInfo.Dependencies = strings.Split(value, ",") + for i, dep := range pkgInfo.Dependencies { + pkgInfo.Dependencies[i] = strings.TrimSpace(strings.Trim(dep, "\"")) + } + } + case "files": + // Parse files array + value = strings.Trim(value, "[]") + if value != "" { + pkgInfo.Files = strings.Split(value, ",") + for i, file := range pkgInfo.Files { + pkgInfo.Files[i] = strings.TrimSpace(strings.Trim(file, "\"")) + } + } + case "install_date": + pkgInfo.InstallDate = value + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + // Validate that we actually parsed some data + if pkgInfo.Name == "" { + return nil, fmt.Errorf("invalid package info: missing name") + } + + return &pkgInfo, nil +} + +// VerifyPackage verifies package integrity +func (p *Packager) VerifyPackage(archivePath string) error { + // Check if file exists + if _, err := os.Stat(archivePath); err != nil { + return fmt.Errorf("package file does not exist: %w", err) + } + + // Try to extract to verify integrity + tmpDir, err := os.MkdirTemp("", "verify-") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + return p.Extract(archivePath, tmpDir) +} + +// ListPackageContents lists contents of a package +func (p *Packager) ListPackageContents(archivePath string) ([]string, error) { + // Extract to temporary directory + tmpDir, err := os.MkdirTemp("", "list-") + if err != nil { + return nil, fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + if err := p.Extract(archivePath, tmpDir); err != nil { + return nil, fmt.Errorf("failed to extract archive: %w", err) + } + + // List files + return p.listFiles(tmpDir) +} + +// GetPackageDir returns the package directory +func (p *Packager) GetPackageDir(recipe *recipe.Recipe) string { + return recipe.GetPackageDir(p.workDir) +} + +// GetPackageFile returns the package file path +func (p *Packager) GetPackageFile(recipe *recipe.Recipe) string { + return filepath.Join(p.GetPackageDir(recipe), recipe.GetPackageFileName()) +} + +// Clean removes package files +func (p *Packager) Clean(recipe *recipe.Recipe) error { + packageDir := p.GetPackageDir(recipe) + return os.RemoveAll(packageDir) +} + +// pkgInfoFileInfo implements os.FileInfo for package info +type pkgInfoFileInfo struct { + name string + size int64 +} + +func (f *pkgInfoFileInfo) Name() string { return f.name } +func (f *pkgInfoFileInfo) Size() int64 { return f.size } +func (f *pkgInfoFileInfo) Mode() os.FileMode { return 0644 } +func (f *pkgInfoFileInfo) ModTime() time.Time { return time.Now() } +func (f *pkgInfoFileInfo) IsDir() bool { return false } +func (f *pkgInfoFileInfo) Sys() interface{} { return nil } diff --git a/pkg/recipe/recipe.go b/pkg/recipe/recipe.go new file mode 100644 index 0000000..ee17762 --- /dev/null +++ b/pkg/recipe/recipe.go @@ -0,0 +1,110 @@ +package recipe + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +// Recipe represents a package recipe +type Recipe struct { + Name string `toml:"name"` + Version string `toml:"version"` + Description string `toml:"description,omitempty"` + Source Source `toml:"source"` + Build Build `toml:"build"` + Package Package `toml:"package"` + Dependencies []string `toml:"dependencies,omitempty"` + Options map[string]bool `toml:"options,omitempty"` +} + +// Source represents source information +type Source struct { + URL string `toml:"url"` + Sha256 string `toml:"sha256"` + Patches []string `toml:"patches,omitempty"` +} + +// Build represents build configuration +type Build struct { + Commands []string `toml:"commands"` + Env []string `toml:"env,omitempty"` +} + +// Package represents package configuration +type Package struct { + Commands []string `toml:"commands"` +} + +// ParseRecipe parses a recipe from a TOML file +func ParseRecipe(path string) (*Recipe, error) { + // Validate input path + if path == "" { + return nil, fmt.Errorf("recipe path cannot be empty") + } + + // Clean and normalize path + path = filepath.Clean(path) + + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open recipe file %s: %w", path, err) + } + defer file.Close() + + return ParseRecipeFromReader(file) +} + +// ParseRecipeFromReader parses a recipe from an io.Reader +func ParseRecipeFromReader(r io.Reader) (*Recipe, error) { + var recipe Recipe + if _, err := toml.NewDecoder(r).Decode(&recipe); err != nil { + return nil, fmt.Errorf("failed to parse recipe: %w", err) + } + + if recipe.Name == "" { + return nil, fmt.Errorf("recipe must have a name") + } + if recipe.Version == "" { + return nil, fmt.Errorf("recipe must have a version") + } + if recipe.Source.URL == "" { + return nil, fmt.Errorf("recipe must have a source URL") + } + if recipe.Source.Sha256 == "" { + return nil, fmt.Errorf("recipe must have a source SHA256 checksum") + } + if len(recipe.Build.Commands) == 0 { + return nil, fmt.Errorf("recipe must have build commands") + } + + return &recipe, nil +} + +// GetPackageName returns the package name in format name-version +func (r *Recipe) GetPackageName() string { + return fmt.Sprintf("%s-%s", r.Name, r.Version) +} + +// GetPackageFileName returns the package file name +func (r *Recipe) GetPackageFileName() string { + return fmt.Sprintf("%s.pkg.tar.zst", r.GetPackageName()) +} + +// GetPackageDir returns the package directory path +func (r *Recipe) GetPackageDir(baseDir string) string { + return filepath.Join(baseDir, "packages", r.GetPackageName()) +} + +// GetSourceDir returns the source directory path +func (r *Recipe) GetSourceDir(baseDir string) string { + return filepath.Join(baseDir, "sources", r.GetPackageName()) +} + +// GetStagingDir returns the staging directory path +func (r *Recipe) GetStagingDir(baseDir string) string { + return filepath.Join(baseDir, "staging", r.GetPackageName()) +} diff --git a/pkg/types/pkginfo.go b/pkg/types/pkginfo.go new file mode 100644 index 0000000..9690545 --- /dev/null +++ b/pkg/types/pkginfo.go @@ -0,0 +1,11 @@ +package types + +// PkgInfo represents package metadata +type PkgInfo struct { + Name string `toml:"name"` + Version string `toml:"version"` + Description string `toml:"description,omitempty"` + Dependencies []string `toml:"dependencies,omitempty"` + Files []string `toml:"files"` + InstallDate string `toml:"install_date"` +} diff --git a/recipes/zlib.toml b/recipes/zlib.toml new file mode 100644 index 0000000..4ffd52d --- /dev/null +++ b/recipes/zlib.toml @@ -0,0 +1,23 @@ +name = "zlib" +version = "1.3" +description = "Compression library implementing the deflate compression method found in gzip and PKZIP" + +[source] +url = "https://zlib.net/fossils/zlib-1.3.tar.gz" +sha256 = "ff0ba4c292013dbc27530b3a81e1f9a813cd39de01ca5e0f8bf355702efa593e" +patches = [] + +[build] +commands = [ + "./configure --prefix=/usr --shared", + "make -j${jobs}" +] + +[package] +commands = [ + "make DESTDIR=${pkgdir} install" +] +env = [ + "CFLAGS=-O2 -fPIC" +] + diff --git a/zsvo b/zsvo new file mode 100755 index 0000000..300d574 Binary files /dev/null and b/zsvo differ