diff --git a/cmd/core/cmd/build.go b/cmd/core/cmd/build.go index 288e921..cf9b911 100644 --- a/cmd/core/cmd/build.go +++ b/cmd/core/cmd/build.go @@ -51,8 +51,13 @@ var guiTemplate embed.FS func AddBuildCommand(app *clir.Cli) { buildCmd := app.NewSubCommand("build", "Build projects with auto-detection and cross-compilation") buildCmd.LongDescription("Builds the current project with automatic type detection.\n" + - "Supports Go, Wails, Node.js, and PHP projects.\n" + - "Configuration can be provided via .core/build.yaml or command-line flags.") + "Supports Go, Wails, Docker, LinuxKit, and Taskfile projects.\n" + + "Configuration can be provided via .core/build.yaml or command-line flags.\n\n" + + "Examples:\n" + + " core build # Auto-detect and build\n" + + " core build --type docker # Build Docker image\n" + + " core build --type linuxkit # Build LinuxKit image\n" + + " core build --type linuxkit --config linuxkit.yml --format qcow2-bios") // Flags for the main build command var buildType string @@ -62,20 +67,32 @@ func AddBuildCommand(app *clir.Cli) { var doArchive bool var doChecksum bool - buildCmd.StringFlag("type", "Builder type (go, wails, node, php) - auto-detected if not specified", &buildType) + // Docker/LinuxKit specific flags + var configPath string + var format string + var push bool + var imageName string + + buildCmd.StringFlag("type", "Builder type (go, wails, docker, linuxkit, taskfile) - auto-detected if not specified", &buildType) buildCmd.BoolFlag("ci", "CI mode - minimal output with JSON artifact list at the end", &ciMode) buildCmd.StringFlag("targets", "Comma-separated OS/arch pairs (e.g., linux/amd64,darwin/arm64)", &targets) buildCmd.StringFlag("output", "Output directory for artifacts (default: dist)", &outputDir) buildCmd.BoolFlag("archive", "Create archives (tar.gz for linux/darwin, zip for windows) - default: true", &doArchive) buildCmd.BoolFlag("checksum", "Generate SHA256 checksums and CHECKSUMS.txt - default: true", &doChecksum) + // Docker/LinuxKit specific + buildCmd.StringFlag("config", "Config file path (for linuxkit: YAML config, for docker: Dockerfile)", &configPath) + buildCmd.StringFlag("format", "Output format for linuxkit (iso-bios, qcow2-bios, raw, vmdk)", &format) + buildCmd.BoolFlag("push", "Push Docker image after build (default: false)", &push) + buildCmd.StringFlag("image", "Docker image name (e.g., host-uk/core-devops)", &imageName) + // Set defaults for archive and checksum (true by default) doArchive = true doChecksum = true // Default action for `core build` (no subcommand) buildCmd.Action(func() error { - return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum) + return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName) }) // --- `build from-path` command (legacy PWA/GUI build) --- @@ -102,7 +119,7 @@ func AddBuildCommand(app *clir.Cli) { } // runProjectBuild handles the main `core build` command with auto-detection. -func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool) error { +func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string) error { // Get current working directory as project root projectDir, err := os.Getwd() if err != nil { @@ -185,6 +202,16 @@ func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDi Name: binaryName, Version: buildCfg.Project.Name, // Could be enhanced with git describe LDFlags: buildCfg.Build.LDFlags, + // Docker/LinuxKit specific + Dockerfile: configPath, // Reuse for Dockerfile path + LinuxKitConfig: configPath, + Push: push, + Image: imageName, + } + + // Parse formats for LinuxKit + if format != "" { + cfg.Formats = strings.Split(format, ",") } // Execute build @@ -407,6 +434,12 @@ func getBuilder(projectType build.ProjectType) (build.Builder, error) { return builders.NewWailsBuilder(), nil case build.ProjectTypeGo: return builders.NewGoBuilder(), nil + case build.ProjectTypeDocker: + return builders.NewDockerBuilder(), nil + case build.ProjectTypeLinuxKit: + return builders.NewLinuxKitBuilder(), nil + case build.ProjectTypeTaskfile: + return builders.NewTaskfileBuilder(), nil case build.ProjectTypeNode: return nil, fmt.Errorf("Node.js builder not yet implemented") case build.ProjectTypePHP: @@ -631,8 +664,8 @@ func runBuild(fromPath string) error { return fmt.Errorf("failed to anchor template filesystem: %w", err) } sod := gosod.New(templateFS) - if sod != nil { - return fmt.Errorf("failed to create new sod instance: %w", sod) + if sod == nil { + return fmt.Errorf("failed to create new sod instance") } templateData := map[string]string{"AppName": appName} diff --git a/pkg/build/build.go b/pkg/build/build.go index 78ccdc8..947d589 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -11,10 +11,13 @@ import ( type ProjectType string const ( - ProjectTypeGo ProjectType = "go" - ProjectTypeWails ProjectType = "wails" - ProjectTypeNode ProjectType = "node" - ProjectTypePHP ProjectType = "php" + ProjectTypeGo ProjectType = "go" + ProjectTypeWails ProjectType = "wails" + ProjectTypeNode ProjectType = "node" + ProjectTypePHP ProjectType = "php" + ProjectTypeDocker ProjectType = "docker" + ProjectTypeLinuxKit ProjectType = "linuxkit" + ProjectTypeTaskfile ProjectType = "taskfile" ) // Target represents a build target platform. @@ -48,6 +51,18 @@ type Config struct { Version string // LDFlags are additional linker flags. LDFlags []string + + // Docker-specific config + Dockerfile string // Path to Dockerfile (default: Dockerfile) + Registry string // Container registry (default: ghcr.io) + Image string // Image name (owner/repo format) + Tags []string // Additional tags to apply + BuildArgs map[string]string // Docker build arguments + Push bool // Whether to push after build + + // LinuxKit-specific config + LinuxKitConfig string // Path to LinuxKit YAML config + Formats []string // Output formats (iso, qcow2, raw, vmdk) } // Builder defines the interface for project-specific build implementations. diff --git a/pkg/build/builders/docker.go b/pkg/build/builders/docker.go new file mode 100644 index 0000000..f2f53e7 --- /dev/null +++ b/pkg/build/builders/docker.go @@ -0,0 +1,214 @@ +// Package builders provides build implementations for different project types. +package builders + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/host-uk/core/pkg/build" +) + +// DockerBuilder builds Docker images. +type DockerBuilder struct{} + +// NewDockerBuilder creates a new Docker builder. +func NewDockerBuilder() *DockerBuilder { + return &DockerBuilder{} +} + +// Name returns the builder's identifier. +func (b *DockerBuilder) Name() string { + return "docker" +} + +// Detect checks if a Dockerfile exists in the directory. +func (b *DockerBuilder) Detect(dir string) (bool, error) { + dockerfilePath := filepath.Join(dir, "Dockerfile") + if _, err := os.Stat(dockerfilePath); err == nil { + return true, nil + } + return false, nil +} + +// Build builds Docker images for the specified targets. +func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) { + // Validate docker CLI is available + if err := b.validateDockerCli(); err != nil { + return nil, err + } + + // Ensure buildx is available + if err := b.ensureBuildx(ctx); err != nil { + return nil, err + } + + // Determine Dockerfile path + dockerfile := cfg.Dockerfile + if dockerfile == "" { + dockerfile = filepath.Join(cfg.ProjectDir, "Dockerfile") + } + + // Validate Dockerfile exists + if _, err := os.Stat(dockerfile); err != nil { + return nil, fmt.Errorf("docker.Build: Dockerfile not found: %s", dockerfile) + } + + // Determine image name + imageName := cfg.Image + if imageName == "" { + imageName = cfg.Name + } + if imageName == "" { + imageName = filepath.Base(cfg.ProjectDir) + } + + // Build platform string from targets + var platforms []string + for _, t := range targets { + platforms = append(platforms, fmt.Sprintf("%s/%s", t.OS, t.Arch)) + } + + // If no targets specified, use current platform + if len(platforms) == 0 { + platforms = []string{"linux/amd64"} + } + + // Determine registry + registry := cfg.Registry + if registry == "" { + registry = "ghcr.io" + } + + // Determine tags + tags := cfg.Tags + if len(tags) == 0 { + tags = []string{"latest"} + if cfg.Version != "" { + tags = append(tags, cfg.Version) + } + } + + // Build full image references + var imageRefs []string + for _, tag := range tags { + // Expand version template + expandedTag := strings.ReplaceAll(tag, "{{.Version}}", cfg.Version) + expandedTag = strings.ReplaceAll(expandedTag, "{{Version}}", cfg.Version) + + if registry != "" { + imageRefs = append(imageRefs, fmt.Sprintf("%s/%s:%s", registry, imageName, expandedTag)) + } else { + imageRefs = append(imageRefs, fmt.Sprintf("%s:%s", imageName, expandedTag)) + } + } + + // Build the docker buildx command + args := []string{"buildx", "build"} + + // Multi-platform support + args = append(args, "--platform", strings.Join(platforms, ",")) + + // Add all tags + for _, ref := range imageRefs { + args = append(args, "-t", ref) + } + + // Dockerfile path + args = append(args, "-f", dockerfile) + + // Build arguments + for k, v := range cfg.BuildArgs { + expandedValue := strings.ReplaceAll(v, "{{.Version}}", cfg.Version) + expandedValue = strings.ReplaceAll(expandedValue, "{{Version}}", cfg.Version) + args = append(args, "--build-arg", fmt.Sprintf("%s=%s", k, expandedValue)) + } + + // Always add VERSION build arg if version is set + if cfg.Version != "" { + args = append(args, "--build-arg", fmt.Sprintf("VERSION=%s", cfg.Version)) + } + + // Output to local docker images or push + if cfg.Push { + args = append(args, "--push") + } else { + // For multi-platform builds without push, we need to load or output somewhere + if len(platforms) == 1 { + args = append(args, "--load") + } else { + // Multi-platform builds can't use --load, output to tarball + outputPath := filepath.Join(cfg.OutputDir, fmt.Sprintf("%s.tar", imageName)) + args = append(args, "--output", fmt.Sprintf("type=oci,dest=%s", outputPath)) + } + } + + // Build context (project directory) + args = append(args, cfg.ProjectDir) + + // Create output directory + if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + return nil, fmt.Errorf("docker.Build: failed to create output directory: %w", err) + } + + // Execute build + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Dir = cfg.ProjectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + fmt.Printf("Building Docker image: %s\n", imageName) + fmt.Printf(" Platforms: %s\n", strings.Join(platforms, ", ")) + fmt.Printf(" Tags: %s\n", strings.Join(imageRefs, ", ")) + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("docker.Build: buildx build failed: %w", err) + } + + // Create artifacts for each platform + var artifacts []build.Artifact + for _, t := range targets { + artifacts = append(artifacts, build.Artifact{ + Path: imageRefs[0], // Primary image reference + OS: t.OS, + Arch: t.Arch, + }) + } + + return artifacts, nil +} + +// validateDockerCli checks if the docker CLI is available. +func (b *DockerBuilder) validateDockerCli() error { + cmd := exec.Command("docker", "--version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker: docker CLI not found. Install it from https://docs.docker.com/get-docker/") + } + return nil +} + +// ensureBuildx ensures docker buildx is available and has a builder. +func (b *DockerBuilder) ensureBuildx(ctx context.Context) error { + // Check if buildx is available + cmd := exec.CommandContext(ctx, "docker", "buildx", "version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker: buildx is not available. Install it from https://docs.docker.com/buildx/working-with-buildx/") + } + + // Check if we have a builder, create one if not + cmd = exec.CommandContext(ctx, "docker", "buildx", "inspect", "--bootstrap") + if err := cmd.Run(); err != nil { + // Try to create a builder + cmd = exec.CommandContext(ctx, "docker", "buildx", "create", "--use", "--bootstrap") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker: failed to create buildx builder: %w", err) + } + } + + return nil +} diff --git a/pkg/build/builders/linuxkit.go b/pkg/build/builders/linuxkit.go new file mode 100644 index 0000000..f0ea9ed --- /dev/null +++ b/pkg/build/builders/linuxkit.go @@ -0,0 +1,248 @@ +// Package builders provides build implementations for different project types. +package builders + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/host-uk/core/pkg/build" +) + +// LinuxKitBuilder builds LinuxKit images. +type LinuxKitBuilder struct{} + +// NewLinuxKitBuilder creates a new LinuxKit builder. +func NewLinuxKitBuilder() *LinuxKitBuilder { + return &LinuxKitBuilder{} +} + +// Name returns the builder's identifier. +func (b *LinuxKitBuilder) Name() string { + return "linuxkit" +} + +// Detect checks if a linuxkit.yml or .yml config exists in the directory. +func (b *LinuxKitBuilder) Detect(dir string) (bool, error) { + // Check for linuxkit.yml + if _, err := os.Stat(filepath.Join(dir, "linuxkit.yml")); err == nil { + return true, nil + } + // Check for .core/linuxkit/*.yml + if matches, _ := filepath.Glob(filepath.Join(dir, ".core", "linuxkit", "*.yml")); len(matches) > 0 { + return true, nil + } + return false, nil +} + +// Build builds LinuxKit images for the specified targets. +func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) { + // Validate linuxkit CLI is available + if err := b.validateLinuxKitCli(); err != nil { + return nil, err + } + + // Determine config file path + configPath := cfg.LinuxKitConfig + if configPath == "" { + // Auto-detect + if _, err := os.Stat(filepath.Join(cfg.ProjectDir, "linuxkit.yml")); err == nil { + configPath = filepath.Join(cfg.ProjectDir, "linuxkit.yml") + } else { + // Look in .core/linuxkit/ + matches, _ := filepath.Glob(filepath.Join(cfg.ProjectDir, ".core", "linuxkit", "*.yml")) + if len(matches) > 0 { + configPath = matches[0] + } + } + } + + if configPath == "" { + return nil, fmt.Errorf("linuxkit.Build: no LinuxKit config file found. Specify with --config or create linuxkit.yml") + } + + // Validate config file exists + if _, err := os.Stat(configPath); err != nil { + return nil, fmt.Errorf("linuxkit.Build: config file not found: %s", configPath) + } + + // Determine output formats + formats := cfg.Formats + if len(formats) == 0 { + formats = []string{"qcow2-bios"} // Default to QEMU-compatible format + } + + // Create output directory + outputDir := cfg.OutputDir + if outputDir == "" { + outputDir = filepath.Join(cfg.ProjectDir, "dist") + } + if err := os.MkdirAll(outputDir, 0755); err != nil { + return nil, fmt.Errorf("linuxkit.Build: failed to create output directory: %w", err) + } + + // Determine base name from config file or project name + baseName := cfg.Name + if baseName == "" { + baseName = strings.TrimSuffix(filepath.Base(configPath), ".yml") + } + + // If no targets, default to linux/amd64 + if len(targets) == 0 { + targets = []build.Target{{OS: "linux", Arch: "amd64"}} + } + + var artifacts []build.Artifact + + // Build for each target and format + for _, target := range targets { + // LinuxKit only supports Linux + if target.OS != "linux" { + fmt.Printf("Skipping %s/%s (LinuxKit only supports Linux)\n", target.OS, target.Arch) + continue + } + + for _, format := range formats { + outputName := fmt.Sprintf("%s-%s", baseName, target.Arch) + + args := b.buildLinuxKitArgs(configPath, format, outputName, outputDir, target.Arch) + + cmd := exec.CommandContext(ctx, "linuxkit", args...) + cmd.Dir = cfg.ProjectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + fmt.Printf("Building LinuxKit image: %s (%s, %s)\n", outputName, format, target.Arch) + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("linuxkit.Build: build failed for %s/%s: %w", target.Arch, format, err) + } + + // Determine the actual output file path + artifactPath := b.getArtifactPath(outputDir, outputName, format) + + // Verify the artifact was created + if _, err := os.Stat(artifactPath); err != nil { + // Try alternate naming conventions + artifactPath = b.findArtifact(outputDir, outputName, format) + if artifactPath == "" { + return nil, fmt.Errorf("linuxkit.Build: artifact not found after build: expected %s", b.getArtifactPath(outputDir, outputName, format)) + } + } + + artifacts = append(artifacts, build.Artifact{ + Path: artifactPath, + OS: target.OS, + Arch: target.Arch, + }) + } + } + + return artifacts, nil +} + +// buildLinuxKitArgs builds the arguments for linuxkit build command. +func (b *LinuxKitBuilder) buildLinuxKitArgs(configPath, format, outputName, outputDir, arch string) []string { + args := []string{"build"} + + // Output format + args = append(args, "-format", format) + + // Output name + args = append(args, "-name", outputName) + + // Output directory + args = append(args, "-dir", outputDir) + + // Architecture (if not amd64) + if arch != "amd64" { + args = append(args, "-arch", arch) + } + + // Config file + args = append(args, configPath) + + return args +} + +// getArtifactPath returns the expected path of the built artifact. +func (b *LinuxKitBuilder) getArtifactPath(outputDir, outputName, format string) string { + ext := b.getFormatExtension(format) + return filepath.Join(outputDir, outputName+ext) +} + +// findArtifact searches for the built artifact with various naming conventions. +func (b *LinuxKitBuilder) findArtifact(outputDir, outputName, format string) string { + // LinuxKit can create files with different suffixes + extensions := []string{ + b.getFormatExtension(format), + "-bios" + b.getFormatExtension(format), + "-efi" + b.getFormatExtension(format), + } + + for _, ext := range extensions { + path := filepath.Join(outputDir, outputName+ext) + if _, err := os.Stat(path); err == nil { + return path + } + } + + // Try to find any file matching the output name + matches, _ := filepath.Glob(filepath.Join(outputDir, outputName+"*")) + for _, match := range matches { + // Return first match that looks like an image + ext := filepath.Ext(match) + if ext == ".iso" || ext == ".qcow2" || ext == ".raw" || ext == ".vmdk" || ext == ".vhd" { + return match + } + } + + return "" +} + +// getFormatExtension returns the file extension for a LinuxKit output format. +func (b *LinuxKitBuilder) getFormatExtension(format string) string { + switch format { + case "iso", "iso-bios", "iso-efi": + return ".iso" + case "raw", "raw-bios", "raw-efi": + return ".raw" + case "qcow2", "qcow2-bios", "qcow2-efi": + return ".qcow2" + case "vmdk": + return ".vmdk" + case "vhd": + return ".vhd" + case "gcp": + return ".img.tar.gz" + case "aws": + return ".raw" + default: + return "." + strings.TrimSuffix(format, "-bios") + } +} + +// validateLinuxKitCli checks if the linuxkit CLI is available. +func (b *LinuxKitBuilder) validateLinuxKitCli() error { + // Check PATH first + if _, err := exec.LookPath("linuxkit"); err == nil { + return nil + } + + // Check common locations + paths := []string{ + "/usr/local/bin/linuxkit", + "/opt/homebrew/bin/linuxkit", + } + + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return nil + } + } + + return fmt.Errorf("linuxkit: linuxkit CLI not found. Install with: brew install linuxkit (macOS) or see https://github.com/linuxkit/linuxkit") +} diff --git a/pkg/build/builders/taskfile.go b/pkg/build/builders/taskfile.go new file mode 100644 index 0000000..d338bd9 --- /dev/null +++ b/pkg/build/builders/taskfile.go @@ -0,0 +1,233 @@ +// Package builders provides build implementations for different project types. +package builders + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/host-uk/core/pkg/build" +) + +// TaskfileBuilder builds projects using Taskfile (https://taskfile.dev/). +// This is a generic builder that can handle any project type that has a Taskfile. +type TaskfileBuilder struct{} + +// NewTaskfileBuilder creates a new Taskfile builder. +func NewTaskfileBuilder() *TaskfileBuilder { + return &TaskfileBuilder{} +} + +// Name returns the builder's identifier. +func (b *TaskfileBuilder) Name() string { + return "taskfile" +} + +// Detect checks if a Taskfile exists in the directory. +func (b *TaskfileBuilder) Detect(dir string) (bool, error) { + // Check for Taskfile.yml, Taskfile.yaml, or Taskfile + taskfiles := []string{ + "Taskfile.yml", + "Taskfile.yaml", + "Taskfile", + "taskfile.yml", + "taskfile.yaml", + } + + for _, tf := range taskfiles { + if _, err := os.Stat(filepath.Join(dir, tf)); err == nil { + return true, nil + } + } + return false, nil +} + +// Build runs the Taskfile build task for each target platform. +func (b *TaskfileBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) { + // Validate task CLI is available + if err := b.validateTaskCli(); err != nil { + return nil, err + } + + // Create output directory + outputDir := cfg.OutputDir + if outputDir == "" { + outputDir = filepath.Join(cfg.ProjectDir, "dist") + } + if err := os.MkdirAll(outputDir, 0755); err != nil { + return nil, fmt.Errorf("taskfile.Build: failed to create output directory: %w", err) + } + + var artifacts []build.Artifact + + // If no targets specified, just run the build task once + if len(targets) == 0 { + if err := b.runTask(ctx, cfg, "", ""); err != nil { + return nil, err + } + + // Try to find artifacts in output directory + found := b.findArtifacts(outputDir) + artifacts = append(artifacts, found...) + } else { + // Run build task for each target + for _, target := range targets { + if err := b.runTask(ctx, cfg, target.OS, target.Arch); err != nil { + return nil, err + } + + // Try to find artifacts for this target + found := b.findArtifactsForTarget(outputDir, target) + artifacts = append(artifacts, found...) + } + } + + return artifacts, nil +} + +// runTask executes the Taskfile build task. +func (b *TaskfileBuilder) runTask(ctx context.Context, cfg *build.Config, goos, goarch string) error { + // Build task command + args := []string{"build"} + + // Pass variables if targets are specified + if goos != "" { + args = append(args, fmt.Sprintf("GOOS=%s", goos)) + } + if goarch != "" { + args = append(args, fmt.Sprintf("GOARCH=%s", goarch)) + } + if cfg.OutputDir != "" { + args = append(args, fmt.Sprintf("OUTPUT_DIR=%s", cfg.OutputDir)) + } + if cfg.Name != "" { + args = append(args, fmt.Sprintf("NAME=%s", cfg.Name)) + } + if cfg.Version != "" { + args = append(args, fmt.Sprintf("VERSION=%s", cfg.Version)) + } + + cmd := exec.CommandContext(ctx, "task", args...) + cmd.Dir = cfg.ProjectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Set environment variables + cmd.Env = os.Environ() + if goos != "" { + cmd.Env = append(cmd.Env, fmt.Sprintf("GOOS=%s", goos)) + } + if goarch != "" { + cmd.Env = append(cmd.Env, fmt.Sprintf("GOARCH=%s", goarch)) + } + if cfg.OutputDir != "" { + cmd.Env = append(cmd.Env, fmt.Sprintf("OUTPUT_DIR=%s", cfg.OutputDir)) + } + if cfg.Name != "" { + cmd.Env = append(cmd.Env, fmt.Sprintf("NAME=%s", cfg.Name)) + } + if cfg.Version != "" { + cmd.Env = append(cmd.Env, fmt.Sprintf("VERSION=%s", cfg.Version)) + } + + if goos != "" && goarch != "" { + fmt.Printf("Running task build for %s/%s\n", goos, goarch) + } else { + fmt.Println("Running task build") + } + + if err := cmd.Run(); err != nil { + return fmt.Errorf("taskfile.Build: task build failed: %w", err) + } + + return nil +} + +// findArtifacts searches for built artifacts in the output directory. +func (b *TaskfileBuilder) findArtifacts(outputDir string) []build.Artifact { + var artifacts []build.Artifact + + entries, err := os.ReadDir(outputDir) + if err != nil { + return artifacts + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + // Skip common non-artifact files + name := entry.Name() + if strings.HasPrefix(name, ".") || name == "CHECKSUMS.txt" { + continue + } + + artifacts = append(artifacts, build.Artifact{ + Path: filepath.Join(outputDir, name), + OS: "", + Arch: "", + }) + } + + return artifacts +} + +// findArtifactsForTarget searches for built artifacts for a specific target. +func (b *TaskfileBuilder) findArtifactsForTarget(outputDir string, target build.Target) []build.Artifact { + var artifacts []build.Artifact + + // Look for files matching the target pattern + patterns := []string{ + fmt.Sprintf("*-%s-%s*", target.OS, target.Arch), + fmt.Sprintf("*_%s_%s*", target.OS, target.Arch), + fmt.Sprintf("*-%s*", target.Arch), + } + + for _, pattern := range patterns { + matches, _ := filepath.Glob(filepath.Join(outputDir, pattern)) + for _, match := range matches { + info, err := os.Stat(match) + if err != nil || info.IsDir() { + continue + } + + artifacts = append(artifacts, build.Artifact{ + Path: match, + OS: target.OS, + Arch: target.Arch, + }) + } + + if len(artifacts) > 0 { + break // Found matches, stop looking + } + } + + return artifacts +} + +// validateTaskCli checks if the task CLI is available. +func (b *TaskfileBuilder) validateTaskCli() error { + // Check PATH first + if _, err := exec.LookPath("task"); err == nil { + return nil + } + + // Check common locations + paths := []string{ + "/usr/local/bin/task", + "/opt/homebrew/bin/task", + } + + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return nil + } + } + + return fmt.Errorf("taskfile: task CLI not found. Install with: brew install go-task (macOS), go install github.com/go-task/task/v3/cmd/task@latest, or see https://taskfile.dev/installation/") +} diff --git a/pkg/release/config.go b/pkg/release/config.go index 756678b..ac8053d 100644 --- a/pkg/release/config.go +++ b/pkg/release/config.go @@ -83,6 +83,42 @@ type PublisherConfig struct { Tags []string `yaml:"tags,omitempty"` // BuildArgs are additional Docker build arguments. BuildArgs map[string]string `yaml:"build_args,omitempty"` + + // npm-specific configuration + // Package is the npm package name (e.g., "@host-uk/core"). + Package string `yaml:"package,omitempty"` + // Access is the npm access level: "public" or "restricted". + Access string `yaml:"access,omitempty"` + + // Homebrew-specific configuration + // Tap is the Homebrew tap repository (e.g., "host-uk/homebrew-tap"). + Tap string `yaml:"tap,omitempty"` + // Formula is the formula name (defaults to project name). + Formula string `yaml:"formula,omitempty"` + + // Scoop-specific configuration + // Bucket is the Scoop bucket repository (e.g., "host-uk/scoop-bucket"). + Bucket string `yaml:"bucket,omitempty"` + + // AUR-specific configuration + // Maintainer is the AUR package maintainer (e.g., "Name "). + Maintainer string `yaml:"maintainer,omitempty"` + + // Chocolatey-specific configuration + // Push determines whether to push to Chocolatey (false = generate only). + Push bool `yaml:"push,omitempty"` + + // Official repo configuration (for Homebrew, Scoop) + // When enabled, generates files for PR to official repos. + Official *OfficialConfig `yaml:"official,omitempty"` +} + +// OfficialConfig holds configuration for generating files for official repo PRs. +type OfficialConfig struct { + // Enabled determines whether to generate files for official repos. + Enabled bool `yaml:"enabled"` + // Output is the directory to write generated files. + Output string `yaml:"output,omitempty"` } // ChangelogConfig holds changelog generation settings. diff --git a/pkg/release/publishers/aur.go b/pkg/release/publishers/aur.go new file mode 100644 index 0000000..3dc7016 --- /dev/null +++ b/pkg/release/publishers/aur.go @@ -0,0 +1,297 @@ +// Package publishers provides release publishing implementations. +package publishers + +import ( + "bytes" + "context" + "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" + + "github.com/host-uk/core/pkg/build" +) + +//go:embed templates/aur/*.tmpl +var aurTemplates embed.FS + +// AURConfig holds AUR-specific configuration. +type AURConfig struct { + // Package is the AUR package name. + Package string + // Maintainer is the package maintainer (e.g., "Name "). + Maintainer string + // Official config for generating files for official repo PRs. + Official *OfficialConfig +} + +// AURPublisher publishes releases to AUR. +type AURPublisher struct{} + +// NewAURPublisher creates a new AUR publisher. +func NewAURPublisher() *AURPublisher { + return &AURPublisher{} +} + +// Name returns the publisher's identifier. +func (p *AURPublisher) Name() string { + return "aur" +} + +// Publish publishes the release to AUR. +func (p *AURPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { + cfg := p.parseConfig(pubCfg, relCfg) + + if cfg.Maintainer == "" { + return fmt.Errorf("aur.Publish: maintainer is required (set publish.aur.maintainer in config)") + } + + repo := "" + if relCfg != nil { + repo = relCfg.GetRepository() + } + if repo == "" { + detectedRepo, err := detectRepository(release.ProjectDir) + if err != nil { + return fmt.Errorf("aur.Publish: could not determine repository: %w", err) + } + repo = detectedRepo + } + + projectName := "" + if relCfg != nil { + projectName = relCfg.GetProjectName() + } + if projectName == "" { + parts := strings.Split(repo, "/") + projectName = parts[len(parts)-1] + } + + packageName := cfg.Package + if packageName == "" { + packageName = projectName + } + + version := strings.TrimPrefix(release.Version, "v") + checksums := buildChecksumMap(release.Artifacts) + + data := aurTemplateData{ + PackageName: packageName, + Description: fmt.Sprintf("%s CLI", projectName), + Repository: repo, + Version: version, + License: "MIT", + BinaryName: projectName, + Maintainer: cfg.Maintainer, + Checksums: checksums, + } + + if dryRun { + return p.dryRunPublish(data, cfg) + } + + return p.executePublish(ctx, release.ProjectDir, data, cfg) +} + +type aurTemplateData struct { + PackageName string + Description string + Repository string + Version string + License string + BinaryName string + Maintainer string + Checksums ChecksumMap +} + +func (p *AURPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig) AURConfig { + cfg := AURConfig{} + + if ext, ok := pubCfg.Extended.(map[string]any); ok { + if pkg, ok := ext["package"].(string); ok && pkg != "" { + cfg.Package = pkg + } + if maintainer, ok := ext["maintainer"].(string); ok && maintainer != "" { + cfg.Maintainer = maintainer + } + if official, ok := ext["official"].(map[string]any); ok { + cfg.Official = &OfficialConfig{} + if enabled, ok := official["enabled"].(bool); ok { + cfg.Official.Enabled = enabled + } + if output, ok := official["output"].(string); ok { + cfg.Official.Output = output + } + } + } + + return cfg +} + +func (p *AURPublisher) dryRunPublish(data aurTemplateData, cfg AURConfig) error { + fmt.Println() + fmt.Println("=== DRY RUN: AUR Publish ===") + fmt.Println() + fmt.Printf("Package: %s-bin\n", data.PackageName) + fmt.Printf("Version: %s\n", data.Version) + fmt.Printf("Maintainer: %s\n", data.Maintainer) + fmt.Printf("Repository: %s\n", data.Repository) + fmt.Println() + + pkgbuild, err := p.renderTemplate("templates/aur/PKGBUILD.tmpl", data) + if err != nil { + return fmt.Errorf("aur.dryRunPublish: %w", err) + } + fmt.Println("Generated PKGBUILD:") + fmt.Println("---") + fmt.Println(pkgbuild) + fmt.Println("---") + fmt.Println() + + srcinfo, err := p.renderTemplate("templates/aur/.SRCINFO.tmpl", data) + if err != nil { + return fmt.Errorf("aur.dryRunPublish: %w", err) + } + fmt.Println("Generated .SRCINFO:") + fmt.Println("---") + fmt.Println(srcinfo) + fmt.Println("---") + fmt.Println() + + fmt.Printf("Would push to AUR: ssh://aur@aur.archlinux.org/%s-bin.git\n", data.PackageName) + fmt.Println() + fmt.Println("=== END DRY RUN ===") + + return nil +} + +func (p *AURPublisher) executePublish(ctx context.Context, projectDir string, data aurTemplateData, cfg AURConfig) error { + pkgbuild, err := p.renderTemplate("templates/aur/PKGBUILD.tmpl", data) + if err != nil { + return fmt.Errorf("aur.Publish: failed to render PKGBUILD: %w", err) + } + + srcinfo, err := p.renderTemplate("templates/aur/.SRCINFO.tmpl", data) + if err != nil { + return fmt.Errorf("aur.Publish: failed to render .SRCINFO: %w", err) + } + + // If official config is enabled, write to output directory + if cfg.Official != nil && cfg.Official.Enabled { + output := cfg.Official.Output + if output == "" { + output = filepath.Join(projectDir, "dist", "aur") + } else if !filepath.IsAbs(output) { + output = filepath.Join(projectDir, output) + } + + if err := os.MkdirAll(output, 0755); err != nil { + return fmt.Errorf("aur.Publish: failed to create output directory: %w", err) + } + + pkgbuildPath := filepath.Join(output, "PKGBUILD") + if err := os.WriteFile(pkgbuildPath, []byte(pkgbuild), 0644); err != nil { + return fmt.Errorf("aur.Publish: failed to write PKGBUILD: %w", err) + } + + srcinfoPath := filepath.Join(output, ".SRCINFO") + if err := os.WriteFile(srcinfoPath, []byte(srcinfo), 0644); err != nil { + return fmt.Errorf("aur.Publish: failed to write .SRCINFO: %w", err) + } + fmt.Printf("Wrote AUR files: %s\n", output) + } + + // Push to AUR if not in official-only mode + if cfg.Official == nil || !cfg.Official.Enabled { + if err := p.pushToAUR(ctx, data, pkgbuild, srcinfo); err != nil { + return err + } + } + + return nil +} + +func (p *AURPublisher) pushToAUR(ctx context.Context, data aurTemplateData, pkgbuild, srcinfo string) error { + aurURL := fmt.Sprintf("ssh://aur@aur.archlinux.org/%s-bin.git", data.PackageName) + + tmpDir, err := os.MkdirTemp("", "aur-package-*") + if err != nil { + return fmt.Errorf("aur.Publish: failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Clone existing AUR repo (or initialize new one) + fmt.Printf("Cloning AUR package %s-bin...\n", data.PackageName) + cmd := exec.CommandContext(ctx, "git", "clone", aurURL, tmpDir) + if err := cmd.Run(); err != nil { + // If clone fails, init a new repo + cmd = exec.CommandContext(ctx, "git", "init", tmpDir) + if err := cmd.Run(); err != nil { + return fmt.Errorf("aur.Publish: failed to initialize repo: %w", err) + } + cmd = exec.CommandContext(ctx, "git", "-C", tmpDir, "remote", "add", "origin", aurURL) + if err := cmd.Run(); err != nil { + return fmt.Errorf("aur.Publish: failed to add remote: %w", err) + } + } + + // Write files + if err := os.WriteFile(filepath.Join(tmpDir, "PKGBUILD"), []byte(pkgbuild), 0644); err != nil { + return fmt.Errorf("aur.Publish: failed to write PKGBUILD: %w", err) + } + if err := os.WriteFile(filepath.Join(tmpDir, ".SRCINFO"), []byte(srcinfo), 0644); err != nil { + return fmt.Errorf("aur.Publish: failed to write .SRCINFO: %w", err) + } + + commitMsg := fmt.Sprintf("Update to %s", data.Version) + + cmd = exec.CommandContext(ctx, "git", "add", ".") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + return fmt.Errorf("aur.Publish: git add failed: %w", err) + } + + cmd = exec.CommandContext(ctx, "git", "commit", "-m", commitMsg) + cmd.Dir = tmpDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("aur.Publish: git commit failed: %w", err) + } + + cmd = exec.CommandContext(ctx, "git", "push", "origin", "master") + cmd.Dir = tmpDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("aur.Publish: git push failed: %w", err) + } + + fmt.Printf("Published to AUR: https://aur.archlinux.org/packages/%s-bin\n", data.PackageName) + return nil +} + +func (p *AURPublisher) renderTemplate(name string, data aurTemplateData) (string, error) { + content, err := aurTemplates.ReadFile(name) + if err != nil { + return "", fmt.Errorf("failed to read template %s: %w", name, err) + } + + tmpl, err := template.New(filepath.Base(name)).Parse(string(content)) + if err != nil { + return "", fmt.Errorf("failed to parse template %s: %w", name, err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template %s: %w", name, err) + } + + return buf.String(), nil +} + +// Ensure build package is used +var _ = build.Artifact{} diff --git a/pkg/release/publishers/chocolatey.go b/pkg/release/publishers/chocolatey.go new file mode 100644 index 0000000..060bed6 --- /dev/null +++ b/pkg/release/publishers/chocolatey.go @@ -0,0 +1,277 @@ +// Package publishers provides release publishing implementations. +package publishers + +import ( + "bytes" + "context" + "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" + + "github.com/host-uk/core/pkg/build" +) + +//go:embed templates/chocolatey/*.tmpl templates/chocolatey/tools/*.tmpl +var chocolateyTemplates embed.FS + +// ChocolateyConfig holds Chocolatey-specific configuration. +type ChocolateyConfig struct { + // Package is the Chocolatey package name. + Package string + // Push determines whether to push to Chocolatey (false = generate only). + Push bool + // Official config for generating files for official repo PRs. + Official *OfficialConfig +} + +// ChocolateyPublisher publishes releases to Chocolatey. +type ChocolateyPublisher struct{} + +// NewChocolateyPublisher creates a new Chocolatey publisher. +func NewChocolateyPublisher() *ChocolateyPublisher { + return &ChocolateyPublisher{} +} + +// Name returns the publisher's identifier. +func (p *ChocolateyPublisher) Name() string { + return "chocolatey" +} + +// Publish publishes the release to Chocolatey. +func (p *ChocolateyPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { + cfg := p.parseConfig(pubCfg, relCfg) + + repo := "" + if relCfg != nil { + repo = relCfg.GetRepository() + } + if repo == "" { + detectedRepo, err := detectRepository(release.ProjectDir) + if err != nil { + return fmt.Errorf("chocolatey.Publish: could not determine repository: %w", err) + } + repo = detectedRepo + } + + projectName := "" + if relCfg != nil { + projectName = relCfg.GetProjectName() + } + if projectName == "" { + parts := strings.Split(repo, "/") + projectName = parts[len(parts)-1] + } + + packageName := cfg.Package + if packageName == "" { + packageName = projectName + } + + version := strings.TrimPrefix(release.Version, "v") + checksums := buildChecksumMap(release.Artifacts) + + // Extract authors from repository + authors := strings.Split(repo, "/")[0] + + data := chocolateyTemplateData{ + PackageName: packageName, + Title: fmt.Sprintf("%s CLI", strings.Title(projectName)), + Description: fmt.Sprintf("%s CLI", projectName), + Repository: repo, + Version: version, + License: "MIT", + BinaryName: projectName, + Authors: authors, + Tags: fmt.Sprintf("cli %s", projectName), + Checksums: checksums, + } + + if dryRun { + return p.dryRunPublish(data, cfg) + } + + return p.executePublish(ctx, release.ProjectDir, data, cfg) +} + +type chocolateyTemplateData struct { + PackageName string + Title string + Description string + Repository string + Version string + License string + BinaryName string + Authors string + Tags string + Checksums ChecksumMap +} + +func (p *ChocolateyPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig) ChocolateyConfig { + cfg := ChocolateyConfig{ + Push: false, // Default to generate only + } + + if ext, ok := pubCfg.Extended.(map[string]any); ok { + if pkg, ok := ext["package"].(string); ok && pkg != "" { + cfg.Package = pkg + } + if push, ok := ext["push"].(bool); ok { + cfg.Push = push + } + if official, ok := ext["official"].(map[string]any); ok { + cfg.Official = &OfficialConfig{} + if enabled, ok := official["enabled"].(bool); ok { + cfg.Official.Enabled = enabled + } + if output, ok := official["output"].(string); ok { + cfg.Official.Output = output + } + } + } + + return cfg +} + +func (p *ChocolateyPublisher) dryRunPublish(data chocolateyTemplateData, cfg ChocolateyConfig) error { + fmt.Println() + fmt.Println("=== DRY RUN: Chocolatey Publish ===") + fmt.Println() + fmt.Printf("Package: %s\n", data.PackageName) + fmt.Printf("Version: %s\n", data.Version) + fmt.Printf("Push: %t\n", cfg.Push) + fmt.Printf("Repository: %s\n", data.Repository) + fmt.Println() + + nuspec, err := p.renderTemplate("templates/chocolatey/package.nuspec.tmpl", data) + if err != nil { + return fmt.Errorf("chocolatey.dryRunPublish: %w", err) + } + fmt.Println("Generated package.nuspec:") + fmt.Println("---") + fmt.Println(nuspec) + fmt.Println("---") + fmt.Println() + + install, err := p.renderTemplate("templates/chocolatey/tools/chocolateyinstall.ps1.tmpl", data) + if err != nil { + return fmt.Errorf("chocolatey.dryRunPublish: %w", err) + } + fmt.Println("Generated chocolateyinstall.ps1:") + fmt.Println("---") + fmt.Println(install) + fmt.Println("---") + fmt.Println() + + if cfg.Push { + fmt.Println("Would push to Chocolatey community repo") + } else { + fmt.Println("Would generate package files only (push=false)") + } + fmt.Println() + fmt.Println("=== END DRY RUN ===") + + return nil +} + +func (p *ChocolateyPublisher) executePublish(ctx context.Context, projectDir string, data chocolateyTemplateData, cfg ChocolateyConfig) error { + nuspec, err := p.renderTemplate("templates/chocolatey/package.nuspec.tmpl", data) + if err != nil { + return fmt.Errorf("chocolatey.Publish: failed to render nuspec: %w", err) + } + + install, err := p.renderTemplate("templates/chocolatey/tools/chocolateyinstall.ps1.tmpl", data) + if err != nil { + return fmt.Errorf("chocolatey.Publish: failed to render install script: %w", err) + } + + // Create package directory + output := filepath.Join(projectDir, "dist", "chocolatey") + if cfg.Official != nil && cfg.Official.Enabled && cfg.Official.Output != "" { + output = cfg.Official.Output + if !filepath.IsAbs(output) { + output = filepath.Join(projectDir, output) + } + } + + toolsDir := filepath.Join(output, "tools") + if err := os.MkdirAll(toolsDir, 0755); err != nil { + return fmt.Errorf("chocolatey.Publish: failed to create output directory: %w", err) + } + + // Write files + nuspecPath := filepath.Join(output, fmt.Sprintf("%s.nuspec", data.PackageName)) + if err := os.WriteFile(nuspecPath, []byte(nuspec), 0644); err != nil { + return fmt.Errorf("chocolatey.Publish: failed to write nuspec: %w", err) + } + + installPath := filepath.Join(toolsDir, "chocolateyinstall.ps1") + if err := os.WriteFile(installPath, []byte(install), 0644); err != nil { + return fmt.Errorf("chocolatey.Publish: failed to write install script: %w", err) + } + + fmt.Printf("Wrote Chocolatey package files: %s\n", output) + + // Push to Chocolatey if configured + if cfg.Push { + if err := p.pushToChocolatey(ctx, output, data); err != nil { + return err + } + } + + return nil +} + +func (p *ChocolateyPublisher) pushToChocolatey(ctx context.Context, packageDir string, data chocolateyTemplateData) error { + // Check for CHOCOLATEY_API_KEY + apiKey := os.Getenv("CHOCOLATEY_API_KEY") + if apiKey == "" { + return fmt.Errorf("chocolatey.Publish: CHOCOLATEY_API_KEY environment variable is required for push") + } + + // Pack the package + nupkgPath := filepath.Join(packageDir, fmt.Sprintf("%s.%s.nupkg", data.PackageName, data.Version)) + + cmd := exec.CommandContext(ctx, "choco", "pack", filepath.Join(packageDir, fmt.Sprintf("%s.nuspec", data.PackageName)), "-OutputDirectory", packageDir) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("chocolatey.Publish: choco pack failed: %w", err) + } + + // Push the package + cmd = exec.CommandContext(ctx, "choco", "push", nupkgPath, "--source", "https://push.chocolatey.org/", "--api-key", apiKey) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("chocolatey.Publish: choco push failed: %w", err) + } + + fmt.Printf("Published to Chocolatey: https://community.chocolatey.org/packages/%s\n", data.PackageName) + return nil +} + +func (p *ChocolateyPublisher) renderTemplate(name string, data chocolateyTemplateData) (string, error) { + content, err := chocolateyTemplates.ReadFile(name) + if err != nil { + return "", fmt.Errorf("failed to read template %s: %w", name, err) + } + + tmpl, err := template.New(filepath.Base(name)).Parse(string(content)) + if err != nil { + return "", fmt.Errorf("failed to parse template %s: %w", name, err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template %s: %w", name, err) + } + + return buf.String(), nil +} + +// Ensure build package is used +var _ = build.Artifact{} diff --git a/pkg/release/publishers/homebrew.go b/pkg/release/publishers/homebrew.go new file mode 100644 index 0000000..4d92261 --- /dev/null +++ b/pkg/release/publishers/homebrew.go @@ -0,0 +1,355 @@ +// Package publishers provides release publishing implementations. +package publishers + +import ( + "bytes" + "context" + "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" + + "github.com/host-uk/core/pkg/build" +) + +//go:embed templates/homebrew/*.tmpl +var homebrewTemplates embed.FS + +// HomebrewConfig holds Homebrew-specific configuration. +type HomebrewConfig struct { + // Tap is the Homebrew tap repository (e.g., "host-uk/homebrew-tap"). + Tap string + // Formula is the formula name (defaults to project name). + Formula string + // Official config for generating files for official repo PRs. + Official *OfficialConfig +} + +// OfficialConfig holds configuration for generating files for official repo PRs. +type OfficialConfig struct { + // Enabled determines whether to generate files for official repos. + Enabled bool + // Output is the directory to write generated files. + Output string +} + +// HomebrewPublisher publishes releases to Homebrew. +type HomebrewPublisher struct{} + +// NewHomebrewPublisher creates a new Homebrew publisher. +func NewHomebrewPublisher() *HomebrewPublisher { + return &HomebrewPublisher{} +} + +// Name returns the publisher's identifier. +func (p *HomebrewPublisher) Name() string { + return "homebrew" +} + +// Publish publishes the release to Homebrew. +func (p *HomebrewPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { + // Parse config + cfg := p.parseConfig(pubCfg, relCfg) + + // Validate configuration + if cfg.Tap == "" && (cfg.Official == nil || !cfg.Official.Enabled) { + return fmt.Errorf("homebrew.Publish: tap is required (set publish.homebrew.tap in config)") + } + + // Get repository and project info + repo := "" + if relCfg != nil { + repo = relCfg.GetRepository() + } + if repo == "" { + detectedRepo, err := detectRepository(release.ProjectDir) + if err != nil { + return fmt.Errorf("homebrew.Publish: could not determine repository: %w", err) + } + repo = detectedRepo + } + + projectName := "" + if relCfg != nil { + projectName = relCfg.GetProjectName() + } + if projectName == "" { + parts := strings.Split(repo, "/") + projectName = parts[len(parts)-1] + } + + formulaName := cfg.Formula + if formulaName == "" { + formulaName = projectName + } + + // Strip leading 'v' from version + version := strings.TrimPrefix(release.Version, "v") + + // Build checksums map from artifacts + checksums := buildChecksumMap(release.Artifacts) + + // Template data + data := homebrewTemplateData{ + FormulaClass: toFormulaClass(formulaName), + Description: fmt.Sprintf("%s CLI", projectName), + Repository: repo, + Version: version, + License: "MIT", + BinaryName: projectName, + Checksums: checksums, + } + + if dryRun { + return p.dryRunPublish(data, cfg) + } + + return p.executePublish(ctx, release.ProjectDir, data, cfg) +} + +// homebrewTemplateData holds data for Homebrew templates. +type homebrewTemplateData struct { + FormulaClass string + Description string + Repository string + Version string + License string + BinaryName string + Checksums ChecksumMap +} + +// ChecksumMap holds checksums for different platform/arch combinations. +type ChecksumMap struct { + DarwinAmd64 string + DarwinArm64 string + LinuxAmd64 string + LinuxArm64 string + WindowsAmd64 string + WindowsArm64 string +} + +// parseConfig extracts Homebrew-specific configuration. +func (p *HomebrewPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig) HomebrewConfig { + cfg := HomebrewConfig{ + Tap: "", + Formula: "", + } + + if ext, ok := pubCfg.Extended.(map[string]any); ok { + if tap, ok := ext["tap"].(string); ok && tap != "" { + cfg.Tap = tap + } + if formula, ok := ext["formula"].(string); ok && formula != "" { + cfg.Formula = formula + } + if official, ok := ext["official"].(map[string]any); ok { + cfg.Official = &OfficialConfig{} + if enabled, ok := official["enabled"].(bool); ok { + cfg.Official.Enabled = enabled + } + if output, ok := official["output"].(string); ok { + cfg.Official.Output = output + } + } + } + + return cfg +} + +// dryRunPublish shows what would be done. +func (p *HomebrewPublisher) dryRunPublish(data homebrewTemplateData, cfg HomebrewConfig) error { + fmt.Println() + fmt.Println("=== DRY RUN: Homebrew Publish ===") + fmt.Println() + fmt.Printf("Formula: %s\n", data.FormulaClass) + fmt.Printf("Version: %s\n", data.Version) + fmt.Printf("Tap: %s\n", cfg.Tap) + fmt.Printf("Repository: %s\n", data.Repository) + fmt.Println() + + // Generate and show formula + formula, err := p.renderTemplate("templates/homebrew/formula.rb.tmpl", data) + if err != nil { + return fmt.Errorf("homebrew.dryRunPublish: %w", err) + } + fmt.Println("Generated formula.rb:") + fmt.Println("---") + fmt.Println(formula) + fmt.Println("---") + fmt.Println() + + if cfg.Tap != "" { + fmt.Printf("Would commit to tap: %s\n", cfg.Tap) + } + if cfg.Official != nil && cfg.Official.Enabled { + output := cfg.Official.Output + if output == "" { + output = "dist/homebrew" + } + fmt.Printf("Would write files for official PR to: %s\n", output) + } + fmt.Println() + fmt.Println("=== END DRY RUN ===") + + return nil +} + +// executePublish creates the formula and commits to tap. +func (p *HomebrewPublisher) executePublish(ctx context.Context, projectDir string, data homebrewTemplateData, cfg HomebrewConfig) error { + // Generate formula + formula, err := p.renderTemplate("templates/homebrew/formula.rb.tmpl", data) + if err != nil { + return fmt.Errorf("homebrew.Publish: failed to render formula: %w", err) + } + + // If official config is enabled, write to output directory + if cfg.Official != nil && cfg.Official.Enabled { + output := cfg.Official.Output + if output == "" { + output = filepath.Join(projectDir, "dist", "homebrew") + } else if !filepath.IsAbs(output) { + output = filepath.Join(projectDir, output) + } + + if err := os.MkdirAll(output, 0755); err != nil { + return fmt.Errorf("homebrew.Publish: failed to create output directory: %w", err) + } + + formulaPath := filepath.Join(output, fmt.Sprintf("%s.rb", strings.ToLower(data.FormulaClass))) + if err := os.WriteFile(formulaPath, []byte(formula), 0644); err != nil { + return fmt.Errorf("homebrew.Publish: failed to write formula: %w", err) + } + fmt.Printf("Wrote Homebrew formula for official PR: %s\n", formulaPath) + } + + // If tap is configured, commit to it + if cfg.Tap != "" { + if err := p.commitToTap(ctx, cfg.Tap, data, formula); err != nil { + return err + } + } + + return nil +} + +// commitToTap commits the formula to the tap repository. +func (p *HomebrewPublisher) commitToTap(ctx context.Context, tap string, data homebrewTemplateData, formula string) error { + // Clone tap repo to temp directory + tmpDir, err := os.MkdirTemp("", "homebrew-tap-*") + if err != nil { + return fmt.Errorf("homebrew.Publish: failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Clone the tap + fmt.Printf("Cloning tap %s...\n", tap) + cmd := exec.CommandContext(ctx, "gh", "repo", "clone", tap, tmpDir, "--", "--depth=1") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("homebrew.Publish: failed to clone tap: %w", err) + } + + // Ensure Formula directory exists + formulaDir := filepath.Join(tmpDir, "Formula") + if err := os.MkdirAll(formulaDir, 0755); err != nil { + return fmt.Errorf("homebrew.Publish: failed to create Formula directory: %w", err) + } + + // Write formula + formulaPath := filepath.Join(formulaDir, fmt.Sprintf("%s.rb", strings.ToLower(data.FormulaClass))) + if err := os.WriteFile(formulaPath, []byte(formula), 0644); err != nil { + return fmt.Errorf("homebrew.Publish: failed to write formula: %w", err) + } + + // Git add, commit, push + commitMsg := fmt.Sprintf("Update %s to %s", data.FormulaClass, data.Version) + + cmd = exec.CommandContext(ctx, "git", "add", ".") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + return fmt.Errorf("homebrew.Publish: git add failed: %w", err) + } + + cmd = exec.CommandContext(ctx, "git", "commit", "-m", commitMsg) + cmd.Dir = tmpDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("homebrew.Publish: git commit failed: %w", err) + } + + cmd = exec.CommandContext(ctx, "git", "push") + cmd.Dir = tmpDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("homebrew.Publish: git push failed: %w", err) + } + + fmt.Printf("Updated Homebrew tap: %s\n", tap) + return nil +} + +// renderTemplate renders an embedded template with the given data. +func (p *HomebrewPublisher) renderTemplate(name string, data homebrewTemplateData) (string, error) { + content, err := homebrewTemplates.ReadFile(name) + if err != nil { + return "", fmt.Errorf("failed to read template %s: %w", name, err) + } + + tmpl, err := template.New(filepath.Base(name)).Parse(string(content)) + if err != nil { + return "", fmt.Errorf("failed to parse template %s: %w", name, err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template %s: %w", name, err) + } + + return buf.String(), nil +} + +// toFormulaClass converts a package name to a Ruby class name. +func toFormulaClass(name string) string { + // Convert kebab-case to PascalCase + parts := strings.Split(name, "-") + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + return strings.Join(parts, "") +} + +// buildChecksumMap extracts checksums from artifacts into a structured map. +func buildChecksumMap(artifacts []build.Artifact) ChecksumMap { + checksums := ChecksumMap{} + + for _, a := range artifacts { + // Parse artifact name to determine platform + name := filepath.Base(a.Path) + checksum := a.Checksum + + switch { + case strings.Contains(name, "darwin-amd64"): + checksums.DarwinAmd64 = checksum + case strings.Contains(name, "darwin-arm64"): + checksums.DarwinArm64 = checksum + case strings.Contains(name, "linux-amd64"): + checksums.LinuxAmd64 = checksum + case strings.Contains(name, "linux-arm64"): + checksums.LinuxArm64 = checksum + case strings.Contains(name, "windows-amd64"): + checksums.WindowsAmd64 = checksum + case strings.Contains(name, "windows-arm64"): + checksums.WindowsArm64 = checksum + } + } + + return checksums +} diff --git a/pkg/release/publishers/npm.go b/pkg/release/publishers/npm.go new file mode 100644 index 0000000..9718698 --- /dev/null +++ b/pkg/release/publishers/npm.go @@ -0,0 +1,248 @@ +// Package publishers provides release publishing implementations. +package publishers + +import ( + "bytes" + "context" + "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" +) + +//go:embed templates/npm/*.tmpl +var npmTemplates embed.FS + +// NpmConfig holds npm-specific configuration. +type NpmConfig struct { + // Package is the npm package name (e.g., "@host-uk/core"). + Package string + // Access is the npm access level: "public" or "restricted". + Access string +} + +// NpmPublisher publishes releases to npm using the binary wrapper pattern. +type NpmPublisher struct{} + +// NewNpmPublisher creates a new npm publisher. +func NewNpmPublisher() *NpmPublisher { + return &NpmPublisher{} +} + +// Name returns the publisher's identifier. +func (p *NpmPublisher) Name() string { + return "npm" +} + +// Publish publishes the release to npm. +// It generates a binary wrapper package that downloads the correct platform binary on postinstall. +func (p *NpmPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { + // Parse npm config + npmCfg := p.parseConfig(pubCfg, relCfg) + + // Validate configuration + if npmCfg.Package == "" { + return fmt.Errorf("npm.Publish: package name is required (set publish.npm.package in config)") + } + + // Get repository + repo := "" + if relCfg != nil { + repo = relCfg.GetRepository() + } + if repo == "" { + detectedRepo, err := detectRepository(release.ProjectDir) + if err != nil { + return fmt.Errorf("npm.Publish: could not determine repository: %w", err) + } + repo = detectedRepo + } + + // Get project name (binary name) + projectName := "" + if relCfg != nil { + projectName = relCfg.GetProjectName() + } + if projectName == "" { + // Try to infer from package name + parts := strings.Split(npmCfg.Package, "/") + projectName = parts[len(parts)-1] + } + + // Strip leading 'v' from version for npm + version := strings.TrimPrefix(release.Version, "v") + + // Template data + data := npmTemplateData{ + Package: npmCfg.Package, + Version: version, + Description: fmt.Sprintf("%s CLI", projectName), + License: "MIT", + Repository: repo, + BinaryName: projectName, + ProjectName: projectName, + Access: npmCfg.Access, + } + + if dryRun { + return p.dryRunPublish(data, &npmCfg) + } + + return p.executePublish(ctx, data, &npmCfg) +} + +// parseConfig extracts npm-specific configuration from the publisher config. +func (p *NpmPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig) NpmConfig { + cfg := NpmConfig{ + Package: "", + Access: "public", + } + + // Override from extended config if present + if ext, ok := pubCfg.Extended.(map[string]any); ok { + if pkg, ok := ext["package"].(string); ok && pkg != "" { + cfg.Package = pkg + } + if access, ok := ext["access"].(string); ok && access != "" { + cfg.Access = access + } + } + + return cfg +} + +// npmTemplateData holds data for npm templates. +type npmTemplateData struct { + Package string + Version string + Description string + License string + Repository string + BinaryName string + ProjectName string + Access string +} + +// dryRunPublish shows what would be done without actually publishing. +func (p *NpmPublisher) dryRunPublish(data npmTemplateData, cfg *NpmConfig) error { + fmt.Println() + fmt.Println("=== DRY RUN: npm Publish ===") + fmt.Println() + fmt.Printf("Package: %s\n", data.Package) + fmt.Printf("Version: %s\n", data.Version) + fmt.Printf("Access: %s\n", data.Access) + fmt.Printf("Repository: %s\n", data.Repository) + fmt.Printf("Binary: %s\n", data.BinaryName) + fmt.Println() + + // Generate and show package.json + pkgJSON, err := p.renderTemplate("templates/npm/package.json.tmpl", data) + if err != nil { + return fmt.Errorf("npm.dryRunPublish: %w", err) + } + fmt.Println("Generated package.json:") + fmt.Println("---") + fmt.Println(pkgJSON) + fmt.Println("---") + fmt.Println() + + fmt.Println("Would run: npm publish --access", data.Access) + fmt.Println() + fmt.Println("=== END DRY RUN ===") + + return nil +} + +// executePublish actually creates and publishes the npm package. +func (p *NpmPublisher) executePublish(ctx context.Context, data npmTemplateData, cfg *NpmConfig) error { + // Check for NPM_TOKEN + if os.Getenv("NPM_TOKEN") == "" { + return fmt.Errorf("npm.Publish: NPM_TOKEN environment variable is required") + } + + // Create temp directory for package + tmpDir, err := os.MkdirTemp("", "npm-publish-*") + if err != nil { + return fmt.Errorf("npm.Publish: failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Create bin directory + binDir := filepath.Join(tmpDir, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + return fmt.Errorf("npm.Publish: failed to create bin directory: %w", err) + } + + // Generate package.json + pkgJSON, err := p.renderTemplate("templates/npm/package.json.tmpl", data) + if err != nil { + return fmt.Errorf("npm.Publish: failed to render package.json: %w", err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(pkgJSON), 0644); err != nil { + return fmt.Errorf("npm.Publish: failed to write package.json: %w", err) + } + + // Generate install.js + installJS, err := p.renderTemplate("templates/npm/install.js.tmpl", data) + if err != nil { + return fmt.Errorf("npm.Publish: failed to render install.js: %w", err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "install.js"), []byte(installJS), 0644); err != nil { + return fmt.Errorf("npm.Publish: failed to write install.js: %w", err) + } + + // Generate run.js + runJS, err := p.renderTemplate("templates/npm/run.js.tmpl", data) + if err != nil { + return fmt.Errorf("npm.Publish: failed to render run.js: %w", err) + } + if err := os.WriteFile(filepath.Join(binDir, "run.js"), []byte(runJS), 0755); err != nil { + return fmt.Errorf("npm.Publish: failed to write run.js: %w", err) + } + + // Create .npmrc with token + npmrc := fmt.Sprintf("//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n") + if err := os.WriteFile(filepath.Join(tmpDir, ".npmrc"), []byte(npmrc), 0600); err != nil { + return fmt.Errorf("npm.Publish: failed to write .npmrc: %w", err) + } + + // Run npm publish + cmd := exec.CommandContext(ctx, "npm", "publish", "--access", data.Access) + cmd.Dir = tmpDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = append(os.Environ(), "NPM_TOKEN="+os.Getenv("NPM_TOKEN")) + + fmt.Printf("Publishing %s@%s to npm...\n", data.Package, data.Version) + if err := cmd.Run(); err != nil { + return fmt.Errorf("npm.Publish: npm publish failed: %w", err) + } + + fmt.Printf("Published %s@%s to npm\n", data.Package, data.Version) + fmt.Printf(" https://www.npmjs.com/package/%s\n", data.Package) + + return nil +} + +// renderTemplate renders an embedded template with the given data. +func (p *NpmPublisher) renderTemplate(name string, data npmTemplateData) (string, error) { + content, err := npmTemplates.ReadFile(name) + if err != nil { + return "", fmt.Errorf("failed to read template %s: %w", name, err) + } + + tmpl, err := template.New(filepath.Base(name)).Parse(string(content)) + if err != nil { + return "", fmt.Errorf("failed to parse template %s: %w", name, err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template %s: %w", name, err) + } + + return buf.String(), nil +} diff --git a/pkg/release/publishers/scoop.go b/pkg/release/publishers/scoop.go new file mode 100644 index 0000000..25e7ee1 --- /dev/null +++ b/pkg/release/publishers/scoop.go @@ -0,0 +1,268 @@ +// Package publishers provides release publishing implementations. +package publishers + +import ( + "bytes" + "context" + "embed" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" + + "github.com/host-uk/core/pkg/build" +) + +//go:embed templates/scoop/*.tmpl +var scoopTemplates embed.FS + +// ScoopConfig holds Scoop-specific configuration. +type ScoopConfig struct { + // Bucket is the Scoop bucket repository (e.g., "host-uk/scoop-bucket"). + Bucket string + // Official config for generating files for official repo PRs. + Official *OfficialConfig +} + +// ScoopPublisher publishes releases to Scoop. +type ScoopPublisher struct{} + +// NewScoopPublisher creates a new Scoop publisher. +func NewScoopPublisher() *ScoopPublisher { + return &ScoopPublisher{} +} + +// Name returns the publisher's identifier. +func (p *ScoopPublisher) Name() string { + return "scoop" +} + +// Publish publishes the release to Scoop. +func (p *ScoopPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { + cfg := p.parseConfig(pubCfg, relCfg) + + if cfg.Bucket == "" && (cfg.Official == nil || !cfg.Official.Enabled) { + return fmt.Errorf("scoop.Publish: bucket is required (set publish.scoop.bucket in config)") + } + + repo := "" + if relCfg != nil { + repo = relCfg.GetRepository() + } + if repo == "" { + detectedRepo, err := detectRepository(release.ProjectDir) + if err != nil { + return fmt.Errorf("scoop.Publish: could not determine repository: %w", err) + } + repo = detectedRepo + } + + projectName := "" + if relCfg != nil { + projectName = relCfg.GetProjectName() + } + if projectName == "" { + parts := strings.Split(repo, "/") + projectName = parts[len(parts)-1] + } + + version := strings.TrimPrefix(release.Version, "v") + checksums := buildChecksumMap(release.Artifacts) + + data := scoopTemplateData{ + PackageName: projectName, + Description: fmt.Sprintf("%s CLI", projectName), + Repository: repo, + Version: version, + License: "MIT", + BinaryName: projectName, + Checksums: checksums, + } + + if dryRun { + return p.dryRunPublish(data, cfg) + } + + return p.executePublish(ctx, release.ProjectDir, data, cfg) +} + +type scoopTemplateData struct { + PackageName string + Description string + Repository string + Version string + License string + BinaryName string + Checksums ChecksumMap +} + +func (p *ScoopPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig) ScoopConfig { + cfg := ScoopConfig{} + + if ext, ok := pubCfg.Extended.(map[string]any); ok { + if bucket, ok := ext["bucket"].(string); ok && bucket != "" { + cfg.Bucket = bucket + } + if official, ok := ext["official"].(map[string]any); ok { + cfg.Official = &OfficialConfig{} + if enabled, ok := official["enabled"].(bool); ok { + cfg.Official.Enabled = enabled + } + if output, ok := official["output"].(string); ok { + cfg.Official.Output = output + } + } + } + + return cfg +} + +func (p *ScoopPublisher) dryRunPublish(data scoopTemplateData, cfg ScoopConfig) error { + fmt.Println() + fmt.Println("=== DRY RUN: Scoop Publish ===") + fmt.Println() + fmt.Printf("Package: %s\n", data.PackageName) + fmt.Printf("Version: %s\n", data.Version) + fmt.Printf("Bucket: %s\n", cfg.Bucket) + fmt.Printf("Repository: %s\n", data.Repository) + fmt.Println() + + manifest, err := p.renderTemplate("templates/scoop/manifest.json.tmpl", data) + if err != nil { + return fmt.Errorf("scoop.dryRunPublish: %w", err) + } + fmt.Println("Generated manifest.json:") + fmt.Println("---") + fmt.Println(manifest) + fmt.Println("---") + fmt.Println() + + if cfg.Bucket != "" { + fmt.Printf("Would commit to bucket: %s\n", cfg.Bucket) + } + if cfg.Official != nil && cfg.Official.Enabled { + output := cfg.Official.Output + if output == "" { + output = "dist/scoop" + } + fmt.Printf("Would write files for official PR to: %s\n", output) + } + fmt.Println() + fmt.Println("=== END DRY RUN ===") + + return nil +} + +func (p *ScoopPublisher) executePublish(ctx context.Context, projectDir string, data scoopTemplateData, cfg ScoopConfig) error { + manifest, err := p.renderTemplate("templates/scoop/manifest.json.tmpl", data) + if err != nil { + return fmt.Errorf("scoop.Publish: failed to render manifest: %w", err) + } + + // If official config is enabled, write to output directory + if cfg.Official != nil && cfg.Official.Enabled { + output := cfg.Official.Output + if output == "" { + output = filepath.Join(projectDir, "dist", "scoop") + } else if !filepath.IsAbs(output) { + output = filepath.Join(projectDir, output) + } + + if err := os.MkdirAll(output, 0755); err != nil { + return fmt.Errorf("scoop.Publish: failed to create output directory: %w", err) + } + + manifestPath := filepath.Join(output, fmt.Sprintf("%s.json", data.PackageName)) + if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil { + return fmt.Errorf("scoop.Publish: failed to write manifest: %w", err) + } + fmt.Printf("Wrote Scoop manifest for official PR: %s\n", manifestPath) + } + + // If bucket is configured, commit to it + if cfg.Bucket != "" { + if err := p.commitToBucket(ctx, cfg.Bucket, data, manifest); err != nil { + return err + } + } + + return nil +} + +func (p *ScoopPublisher) commitToBucket(ctx context.Context, bucket string, data scoopTemplateData, manifest string) error { + tmpDir, err := os.MkdirTemp("", "scoop-bucket-*") + if err != nil { + return fmt.Errorf("scoop.Publish: failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + fmt.Printf("Cloning bucket %s...\n", bucket) + cmd := exec.CommandContext(ctx, "gh", "repo", "clone", bucket, tmpDir, "--", "--depth=1") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("scoop.Publish: failed to clone bucket: %w", err) + } + + // Ensure bucket directory exists + bucketDir := filepath.Join(tmpDir, "bucket") + if _, err := os.Stat(bucketDir); os.IsNotExist(err) { + bucketDir = tmpDir // Some repos put manifests in root + } + + manifestPath := filepath.Join(bucketDir, fmt.Sprintf("%s.json", data.PackageName)) + if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil { + return fmt.Errorf("scoop.Publish: failed to write manifest: %w", err) + } + + commitMsg := fmt.Sprintf("Update %s to %s", data.PackageName, data.Version) + + cmd = exec.CommandContext(ctx, "git", "add", ".") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + return fmt.Errorf("scoop.Publish: git add failed: %w", err) + } + + cmd = exec.CommandContext(ctx, "git", "commit", "-m", commitMsg) + cmd.Dir = tmpDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("scoop.Publish: git commit failed: %w", err) + } + + cmd = exec.CommandContext(ctx, "git", "push") + cmd.Dir = tmpDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("scoop.Publish: git push failed: %w", err) + } + + fmt.Printf("Updated Scoop bucket: %s\n", bucket) + return nil +} + +func (p *ScoopPublisher) renderTemplate(name string, data scoopTemplateData) (string, error) { + content, err := scoopTemplates.ReadFile(name) + if err != nil { + return "", fmt.Errorf("failed to read template %s: %w", name, err) + } + + tmpl, err := template.New(filepath.Base(name)).Parse(string(content)) + if err != nil { + return "", fmt.Errorf("failed to parse template %s: %w", name, err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template %s: %w", name, err) + } + + return buf.String(), nil +} + +// Ensure build package is used +var _ = build.Artifact{} diff --git a/pkg/release/publishers/templates/aur/.SRCINFO.tmpl b/pkg/release/publishers/templates/aur/.SRCINFO.tmpl new file mode 100644 index 0000000..af3ad66 --- /dev/null +++ b/pkg/release/publishers/templates/aur/.SRCINFO.tmpl @@ -0,0 +1,16 @@ +pkgbase = {{.PackageName}}-bin + pkgdesc = {{.Description}} + pkgver = {{.Version}} + pkgrel = 1 + url = https://github.com/{{.Repository}} + arch = x86_64 + arch = aarch64 + license = {{.License}} + provides = {{.PackageName}} + conflicts = {{.PackageName}} + source_x86_64 = {{.PackageName}}-bin-{{.Version}}-x86_64.tar.gz::https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-linux-amd64.tar.gz + sha256sums_x86_64 = {{.Checksums.LinuxAmd64}} + source_aarch64 = {{.PackageName}}-bin-{{.Version}}-aarch64.tar.gz::https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-linux-arm64.tar.gz + sha256sums_aarch64 = {{.Checksums.LinuxArm64}} + +pkgname = {{.PackageName}}-bin diff --git a/pkg/release/publishers/templates/aur/PKGBUILD.tmpl b/pkg/release/publishers/templates/aur/PKGBUILD.tmpl new file mode 100644 index 0000000..61096bf --- /dev/null +++ b/pkg/release/publishers/templates/aur/PKGBUILD.tmpl @@ -0,0 +1,20 @@ +# Maintainer: {{.Maintainer}} +pkgname={{.PackageName}}-bin +pkgver={{.Version}} +pkgrel=1 +pkgdesc="{{.Description}}" +arch=('x86_64' 'aarch64') +url="https://github.com/{{.Repository}}" +license=('{{.License}}') +provides=('{{.PackageName}}') +conflicts=('{{.PackageName}}') + +source_x86_64=("${pkgname}-${pkgver}-x86_64.tar.gz::https://github.com/{{.Repository}}/releases/download/v${pkgver}/{{.BinaryName}}-linux-amd64.tar.gz") +source_aarch64=("${pkgname}-${pkgver}-aarch64.tar.gz::https://github.com/{{.Repository}}/releases/download/v${pkgver}/{{.BinaryName}}-linux-arm64.tar.gz") + +sha256sums_x86_64=('{{.Checksums.LinuxAmd64}}') +sha256sums_aarch64=('{{.Checksums.LinuxArm64}}') + +package() { + install -Dm755 {{.BinaryName}} "${pkgdir}/usr/bin/{{.BinaryName}}" +} diff --git a/pkg/release/publishers/templates/chocolatey/package.nuspec.tmpl b/pkg/release/publishers/templates/chocolatey/package.nuspec.tmpl new file mode 100644 index 0000000..c96ca7d --- /dev/null +++ b/pkg/release/publishers/templates/chocolatey/package.nuspec.tmpl @@ -0,0 +1,18 @@ + + + + {{.PackageName}} + {{.Version}} + {{.Title}} + {{.Authors}} + https://github.com/{{.Repository}} + https://github.com/{{.Repository}}/blob/main/LICENSE + false + {{.Description}} + {{.Tags}} + https://github.com/{{.Repository}}/releases/tag/v{{.Version}} + + + + + diff --git a/pkg/release/publishers/templates/chocolatey/tools/chocolateyinstall.ps1.tmpl b/pkg/release/publishers/templates/chocolatey/tools/chocolateyinstall.ps1.tmpl new file mode 100644 index 0000000..a915be8 --- /dev/null +++ b/pkg/release/publishers/templates/chocolatey/tools/chocolateyinstall.ps1.tmpl @@ -0,0 +1,13 @@ +$ErrorActionPreference = 'Stop' +$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" +$url64 = 'https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-windows-amd64.zip' + +$packageArgs = @{ + packageName = '{{.PackageName}}' + unzipLocation = $toolsDir + url64bit = $url64 + checksum64 = '{{.Checksums.WindowsAmd64}}' + checksumType64 = 'sha256' +} + +Install-ChocolateyZipPackage @packageArgs diff --git a/pkg/release/publishers/templates/homebrew/formula.rb.tmpl b/pkg/release/publishers/templates/homebrew/formula.rb.tmpl new file mode 100644 index 0000000..aa03fcb --- /dev/null +++ b/pkg/release/publishers/templates/homebrew/formula.rb.tmpl @@ -0,0 +1,37 @@ +# typed: false +# frozen_string_literal: true + +class {{.FormulaClass}} < Formula + desc "{{.Description}}" + homepage "https://github.com/{{.Repository}}" + version "{{.Version}}" + license "{{.License}}" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-darwin-arm64.tar.gz" + sha256 "{{.Checksums.DarwinArm64}}" + else + url "https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-darwin-amd64.tar.gz" + sha256 "{{.Checksums.DarwinAmd64}}" + end + end + + on_linux do + if Hardware::CPU.arm? + url "https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-linux-arm64.tar.gz" + sha256 "{{.Checksums.LinuxArm64}}" + else + url "https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-linux-amd64.tar.gz" + sha256 "{{.Checksums.LinuxAmd64}}" + end + end + + def install + bin.install "{{.BinaryName}}" + end + + test do + system "#{bin}/{{.BinaryName}}", "--version" + end +end diff --git a/pkg/release/publishers/templates/npm/install.js.tmpl b/pkg/release/publishers/templates/npm/install.js.tmpl new file mode 100644 index 0000000..bf924f6 --- /dev/null +++ b/pkg/release/publishers/templates/npm/install.js.tmpl @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** + * Binary installer for {{.Package}} + * Downloads the correct binary for the current platform from GitHub releases. + */ + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { spawnSync } = require('child_process'); +const crypto = require('crypto'); + +const PACKAGE_VERSION = '{{.Version}}'; +const GITHUB_REPO = '{{.Repository}}'; +const BINARY_NAME = '{{.BinaryName}}'; + +// Platform/arch mapping +const PLATFORM_MAP = { + darwin: 'darwin', + linux: 'linux', + win32: 'windows', +}; + +const ARCH_MAP = { + x64: 'amd64', + arm64: 'arm64', +}; + +function getPlatformInfo() { + const platform = PLATFORM_MAP[process.platform]; + const arch = ARCH_MAP[process.arch]; + + if (!platform || !arch) { + console.error(`Unsupported platform: ${process.platform}/${process.arch}`); + process.exit(1); + } + + return { platform, arch }; +} + +function getDownloadUrl(platform, arch) { + const ext = platform === 'windows' ? '.zip' : '.tar.gz'; + const name = `${BINARY_NAME}-${platform}-${arch}${ext}`; + return `https://github.com/${GITHUB_REPO}/releases/download/v${PACKAGE_VERSION}/${name}`; +} + +function getChecksumsUrl() { + return `https://github.com/${GITHUB_REPO}/releases/download/v${PACKAGE_VERSION}/checksums.txt`; +} + +function download(url) { + return new Promise((resolve, reject) => { + const request = (url) => { + https.get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + // Follow redirect + request(res.headers.location); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`Failed to download ${url}: HTTP ${res.statusCode}`)); + return; + } + + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks))); + res.on('error', reject); + }).on('error', reject); + }; + request(url); + }); +} + +async function fetchChecksums() { + try { + const data = await download(getChecksumsUrl()); + const checksums = {}; + data.toString().split('\n').forEach((line) => { + const parts = line.trim().split(/\s+/); + if (parts.length === 2) { + checksums[parts[1]] = parts[0]; + } + }); + return checksums; + } catch (err) { + console.warn('Warning: Could not fetch checksums, skipping verification'); + return null; + } +} + +function verifyChecksum(data, expectedHash) { + const actualHash = crypto.createHash('sha256').update(data).digest('hex'); + return actualHash === expectedHash; +} + +function extract(data, destDir, platform) { + const tempFile = path.join(destDir, platform === 'windows' ? 'temp.zip' : 'temp.tar.gz'); + fs.writeFileSync(tempFile, data); + + try { + if (platform === 'windows') { + // Use PowerShell to extract zip + const result = spawnSync('powershell', [ + '-command', + `Expand-Archive -Path '${tempFile}' -DestinationPath '${destDir}' -Force` + ], { stdio: 'ignore' }); + if (result.status !== 0) { + throw new Error('Failed to extract zip'); + } + } else { + const result = spawnSync('tar', ['-xzf', tempFile, '-C', destDir], { stdio: 'ignore' }); + if (result.status !== 0) { + throw new Error('Failed to extract tar.gz'); + } + } + } finally { + fs.unlinkSync(tempFile); + } +} + +async function main() { + const { platform, arch } = getPlatformInfo(); + const binDir = path.join(__dirname, 'bin'); + const binaryPath = path.join(binDir, platform === 'windows' ? `${BINARY_NAME}.exe` : BINARY_NAME); + + // Skip if binary already exists + if (fs.existsSync(binaryPath)) { + console.log(`${BINARY_NAME} binary already installed`); + return; + } + + console.log(`Installing ${BINARY_NAME} v${PACKAGE_VERSION} for ${platform}/${arch}...`); + + // Ensure bin directory exists + if (!fs.existsSync(binDir)) { + fs.mkdirSync(binDir, { recursive: true }); + } + + // Fetch checksums + const checksums = await fetchChecksums(); + + // Download binary + const url = getDownloadUrl(platform, arch); + console.log(`Downloading from ${url}`); + + const data = await download(url); + + // Verify checksum if available + if (checksums) { + const ext = platform === 'windows' ? '.zip' : '.tar.gz'; + const filename = `${BINARY_NAME}-${platform}-${arch}${ext}`; + const expectedHash = checksums[filename]; + if (expectedHash && !verifyChecksum(data, expectedHash)) { + console.error('Checksum verification failed!'); + process.exit(1); + } + console.log('Checksum verified'); + } + + // Extract + extract(data, binDir, platform); + + // Make executable on Unix + if (platform !== 'windows') { + fs.chmodSync(binaryPath, 0o755); + } + + console.log(`${BINARY_NAME} installed successfully`); +} + +main().catch((err) => { + console.error(`Installation failed: ${err.message}`); + process.exit(1); +}); diff --git a/pkg/release/publishers/templates/npm/package.json.tmpl b/pkg/release/publishers/templates/npm/package.json.tmpl new file mode 100644 index 0000000..a7d0962 --- /dev/null +++ b/pkg/release/publishers/templates/npm/package.json.tmpl @@ -0,0 +1,34 @@ +{ + "name": "{{.Package}}", + "version": "{{.Version}}", + "description": "{{.Description}}", + "license": "{{.License}}", + "repository": { + "type": "git", + "url": "https://github.com/{{.Repository}}.git" + }, + "homepage": "https://github.com/{{.Repository}}", + "bugs": { + "url": "https://github.com/{{.Repository}}/issues" + }, + "bin": { + "{{.BinaryName}}": "./bin/run.js" + }, + "scripts": { + "postinstall": "node ./install.js" + }, + "files": [ + "bin/", + "install.js" + ], + "engines": { + "node": ">=14.0.0" + }, + "keywords": [ + "cli", + "{{.ProjectName}}" + ], + "publishConfig": { + "access": "{{.Access}}" + } +} diff --git a/pkg/release/publishers/templates/npm/run.js.tmpl b/pkg/release/publishers/templates/npm/run.js.tmpl new file mode 100644 index 0000000..8a04a68 --- /dev/null +++ b/pkg/release/publishers/templates/npm/run.js.tmpl @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * Binary wrapper for {{.Package}} + * Executes the platform-specific binary. + */ + +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const BINARY_NAME = '{{.BinaryName}}'; + +function getBinaryPath() { + const binDir = path.join(__dirname); + const isWindows = process.platform === 'win32'; + const binaryName = isWindows ? `${BINARY_NAME}.exe` : BINARY_NAME; + return path.join(binDir, binaryName); +} + +function main() { + const binaryPath = getBinaryPath(); + + if (!fs.existsSync(binaryPath)) { + console.error(`Binary not found at ${binaryPath}`); + console.error('Try reinstalling the package: npm install -g {{.Package}}'); + process.exit(1); + } + + const child = spawn(binaryPath, process.argv.slice(2), { + stdio: 'inherit', + windowsHide: true, + }); + + child.on('error', (err) => { + console.error(`Failed to start ${BINARY_NAME}: ${err.message}`); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + } else { + process.exit(code ?? 0); + } + }); +} + +main(); diff --git a/pkg/release/publishers/templates/scoop/manifest.json.tmpl b/pkg/release/publishers/templates/scoop/manifest.json.tmpl new file mode 100644 index 0000000..6455225 --- /dev/null +++ b/pkg/release/publishers/templates/scoop/manifest.json.tmpl @@ -0,0 +1,30 @@ +{ + "version": "{{.Version}}", + "description": "{{.Description}}", + "homepage": "https://github.com/{{.Repository}}", + "license": "{{.License}}", + "architecture": { + "64bit": { + "url": "https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-windows-amd64.zip", + "hash": "{{.Checksums.WindowsAmd64}}" + }, + "arm64": { + "url": "https://github.com/{{.Repository}}/releases/download/v{{.Version}}/{{.BinaryName}}-windows-arm64.zip", + "hash": "{{.Checksums.WindowsArm64}}" + } + }, + "bin": "{{.BinaryName}}.exe", + "checkver": { + "github": "https://github.com/{{.Repository}}" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/{{.Repository}}/releases/download/v$version/{{.BinaryName}}-windows-amd64.zip" + }, + "arm64": { + "url": "https://github.com/{{.Repository}}/releases/download/v$version/{{.BinaryName}}-windows-arm64.zip" + } + } + } +} diff --git a/pkg/release/release.go b/pkg/release/release.go index 880d725..ba51ed0 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -216,6 +216,16 @@ func getPublisher(pubType string) (publishers.Publisher, error) { return publishers.NewLinuxKitPublisher(), nil case "docker": return publishers.NewDockerPublisher(), nil + case "npm": + return publishers.NewNpmPublisher(), nil + case "homebrew": + return publishers.NewHomebrewPublisher(), nil + case "scoop": + return publishers.NewScoopPublisher(), nil + case "aur": + return publishers.NewAURPublisher(), nil + case "chocolatey": + return publishers.NewChocolateyPublisher(), nil default: return nil, fmt.Errorf("unsupported publisher type: %s", pubType) } @@ -257,6 +267,47 @@ func buildExtendedConfig(pubCfg PublisherConfig) map[string]any { ext["build_args"] = args } + // npm-specific config + if pubCfg.Package != "" { + ext["package"] = pubCfg.Package + } + if pubCfg.Access != "" { + ext["access"] = pubCfg.Access + } + + // Homebrew-specific config + if pubCfg.Tap != "" { + ext["tap"] = pubCfg.Tap + } + if pubCfg.Formula != "" { + ext["formula"] = pubCfg.Formula + } + + // Scoop-specific config + if pubCfg.Bucket != "" { + ext["bucket"] = pubCfg.Bucket + } + + // AUR-specific config + if pubCfg.Maintainer != "" { + ext["maintainer"] = pubCfg.Maintainer + } + + // Chocolatey-specific config + if pubCfg.Push { + ext["push"] = pubCfg.Push + } + + // Official repo config (shared by multiple publishers) + if pubCfg.Official != nil { + official := make(map[string]any) + official["enabled"] = pubCfg.Official.Enabled + if pubCfg.Official.Output != "" { + official["output"] = pubCfg.Official.Output + } + ext["official"] = official + } + return ext }