diff --git a/go.mod b/go.mod
index eb0307b0..b47078c1 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/MakeNowJust/heredoc/v2 v2.0.1
github.com/OctopusDeploy/go-octopusdeploy/v2 v2.20.0
+ github.com/bmatcuk/doublestar/v4 v4.4.0
github.com/briandowns/spinner v1.19.0
github.com/google/uuid v1.3.0
github.com/hashicorp/go-multierror v1.1.1
@@ -18,6 +19,7 @@ require (
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.14.0
github.com/stretchr/testify v1.8.1
+ golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15
golang.org/x/term v0.3.0
)
@@ -50,7 +52,6 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
golang.org/x/crypto v0.4.0 // indirect
- golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15 // indirect
golang.org/x/sys v0.3.0 // indirect
golang.org/x/text v0.5.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
diff --git a/go.sum b/go.sum
index 06af14c6..16c27a47 100644
--- a/go.sum
+++ b/go.sum
@@ -46,6 +46,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/OctopusDeploy/go-octopusdeploy/v2 v2.20.0 h1:3IpsaL5PJepA/5WEhUBn7GmXS2uT4GZ0n5N/iQV6JhQ=
github.com/OctopusDeploy/go-octopusdeploy/v2 v2.20.0/go.mod h1:++gnwUI5HaG31oDMaUXrNNjsXfmLn/zWyMmy/5GaFSA=
+github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic=
+github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E=
github.com/briandowns/spinner v1.19.0/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
diff --git a/pkg/cmd/package/nuget/create/create.go b/pkg/cmd/package/nuget/create/create.go
new file mode 100644
index 00000000..599b56da
--- /dev/null
+++ b/pkg/cmd/package/nuget/create/create.go
@@ -0,0 +1,316 @@
+package create
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/AlecAivazis/survey/v2"
+ "github.com/MakeNowJust/heredoc/v2"
+ pack "github.com/OctopusDeploy/cli/pkg/cmd/package/support"
+ "github.com/OctopusDeploy/cli/pkg/constants"
+ "github.com/OctopusDeploy/cli/pkg/factory"
+ "github.com/OctopusDeploy/cli/pkg/surveyext"
+ "github.com/OctopusDeploy/cli/pkg/util"
+ "github.com/OctopusDeploy/cli/pkg/util/flag"
+ "github.com/spf13/cobra"
+)
+
+const (
+ FlagAuthor = "author"
+ FlagTitle = "title"
+ FlagDescription = "description"
+ FlagReleaseNotes = "releaseNotes"
+ FlagReleaseNotesFile = "releaseNotesFile"
+)
+
+type NuPkgCreateFlags struct {
+ Author *flag.Flag[[]string] // this need to be multiple and default to current user
+ Title *flag.Flag[string]
+ Description *flag.Flag[string]
+ ReleaseNotes *flag.Flag[string]
+ ReleaseNotesFile *flag.Flag[string]
+}
+
+type NuPkgCreateOptions struct {
+ *NuPkgCreateFlags
+ *pack.PackageCreateOptions
+}
+
+func NewNuPkgCreateFlags() *NuPkgCreateFlags {
+ return &NuPkgCreateFlags{
+ Author: flag.New[[]string](FlagAuthor, false),
+ Title: flag.New[string](FlagTitle, false),
+ Description: flag.New[string](FlagDescription, false),
+ ReleaseNotes: flag.New[string](FlagReleaseNotes, false),
+ ReleaseNotesFile: flag.New[string](FlagReleaseNotesFile, false),
+ }
+}
+
+func NewCmdCreate(f factory.Factory) *cobra.Command {
+ createFlags := NewNuPkgCreateFlags()
+ packFlags := pack.NewPackageCreateFlags()
+
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create nuget",
+ Long: "Create nuget package",
+ Example: heredoc.Docf(`
+ $ %[1]s package nuget create --id SomePackage --version 1.0.0
+ `, constants.ExecutableName),
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ opts := &NuPkgCreateOptions{
+ NuPkgCreateFlags: createFlags,
+ PackageCreateOptions: pack.NewPackageCreateOptions(f, packFlags, cmd),
+ }
+ return createRun(opts)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.StringVar(&packFlags.Id.Value, packFlags.Id.Name, "", "The ID of the package")
+ flags.StringVarP(&packFlags.Version.Value, packFlags.Version.Name, "v", "", "The version of the package, must be a valid SemVer.")
+ flags.StringVar(&packFlags.BasePath.Value, packFlags.BasePath.Name, "", "Root folder containing the contents to zip.")
+ flags.StringVar(&packFlags.OutFolder.Value, packFlags.OutFolder.Name, "", "Folder into which the zip file will be written.")
+ flags.StringSliceVar(&packFlags.Include.Value, packFlags.Include.Name, []string{}, "Add a file pattern to include, relative to the base path e.g. /bin/*.dll; defaults to \"**\".")
+ flags.BoolVar(&packFlags.Verbose.Value, packFlags.Verbose.Name, false, "Verbose output.")
+ flags.BoolVar(&packFlags.Overwrite.Value, packFlags.Overwrite.Name, false, "Allow an existing package file of the same ID/version to be overwritten.")
+ flags.StringSliceVar(&createFlags.Author.Value, createFlags.Author.Name, []string{}, "Add author/s to the package metadata.")
+ flags.StringVar(&createFlags.Title.Value, createFlags.Title.Name, "", "The title of the package.")
+ flags.StringVar(&createFlags.Description.Value, createFlags.Description.Name, "", "A description of the package, defaults to \"A deployment package created from files on disk.\".")
+ flags.StringVar(&createFlags.ReleaseNotes.Value, createFlags.ReleaseNotes.Name, "", "Release notes for this version of the package.")
+ flags.StringVar(&createFlags.ReleaseNotesFile.Value, createFlags.ReleaseNotesFile.Name, "", "A file containing release notes for this version of the package.")
+ flags.SortFlags = false
+
+ return cmd
+}
+
+func createRun(opts *NuPkgCreateOptions) error {
+ if !opts.NoPrompt {
+ if err := pack.PackageCreatePromptMissing(opts.PackageCreateOptions); err != nil {
+ return err
+ }
+ if err := PromptMissing(opts); err != nil {
+ return err
+ }
+ }
+
+ if opts.Id.Value == "" {
+ return errors.New("must supply a package ID")
+ }
+
+ err := applyDefaultsToUnspecifiedPackageOptions(opts)
+ if err != nil {
+ return err
+ }
+
+ nuspecFilePath := ""
+ if shouldGenerateNuSpec(opts) {
+ defer func() {
+ if nuspecFilePath != "" {
+ err := os.Remove(nuspecFilePath)
+ if err != nil {
+ panic(err)
+ }
+ }
+ }()
+ nuspecFilePath, err = GenerateNuSpec(opts)
+ if err != nil {
+ return err
+ }
+ opts.Include.Value = append(opts.Include.Value, opts.Id.Value+".nuspec")
+ }
+
+ pack.VerboseOut(opts.Writer, opts.Verbose.Value, "Packing \"%s\" version \"%s\"...\n", opts.Id.Value, opts.Version.Value)
+ outFilePath := pack.BuildOutFileName("nupkg", opts.Id.Value, opts.Version.Value)
+
+ err = pack.BuildPackage(opts.PackageCreateOptions, outFilePath)
+ if err != nil {
+ return err
+ }
+
+ if !opts.NoPrompt {
+ autoCmd := flag.GenerateAutomationCmd(
+ opts.CmdPath,
+ opts.Author,
+ opts.Title,
+ opts.Description,
+ opts.ReleaseNotes,
+ opts.ReleaseNotesFile,
+ opts.Id,
+ opts.Version,
+ opts.BasePath,
+ opts.OutFolder,
+ opts.Include,
+ opts.Verbose,
+ opts.Overwrite,
+ )
+ fmt.Fprintf(opts.Writer, "\nAutomation Command: %s\n", autoCmd)
+ }
+
+ return nil
+}
+
+func PromptMissing(opts *NuPkgCreateOptions) error {
+ if len(opts.Author.Value) == 0 {
+ for {
+ author := ""
+ if err := opts.Ask(&survey.Input{
+ Message: "Author (leave blank to continue)",
+ Help: "Add an author to the package metadata.",
+ }, &author); err != nil {
+ return err
+ }
+ if strings.TrimSpace(author) == "" {
+ break
+ }
+ opts.Author.Value = append(opts.Author.Value, author)
+ }
+ }
+
+ if len(opts.Author.Value) > 0 {
+ if opts.Title.Value == "" {
+ if err := opts.Ask(&survey.Input{
+ Message: "Nuspec title",
+ Help: "The title to include in the Nuspec file.",
+ }, &opts.Title.Value); err != nil {
+ return err
+ }
+ }
+
+ if opts.Description.Value == "" {
+ if err := opts.Ask(&survey.Input{
+ Message: "Nuspec description",
+ Help: "The description to include in the Nuspec file.",
+ Default: "A deployment package created from files on disk.",
+ }, &opts.Description.Value); err != nil {
+ return err
+ }
+ }
+
+ if opts.ReleaseNotes.Value == "" {
+ if err := opts.Ask(&surveyext.OctoEditor{
+ Editor: &survey.Editor{
+ Message: "Nuspec release notes",
+ Help: "The release notes to include in the Nuspec file.",
+ },
+ Optional: true,
+ }, &opts.ReleaseNotes.Value); err != nil {
+ return err
+ }
+ }
+
+ if opts.ReleaseNotesFile.Value == "" {
+ if err := opts.Ask(&survey.Input{
+ Message: "Nuspec release notes file",
+ Help: "A path to a release notes file whose contents will be included in the Nuspec file's release notes.",
+ }, &opts.ReleaseNotesFile.Value); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func applyDefaultsToUnspecifiedPackageOptions(opts *NuPkgCreateOptions) error {
+ if opts.Version.Value == "" {
+ opts.Version.Value = pack.BuildTimestampSemVer(time.Now())
+ }
+
+ if opts.BasePath.Value == "" {
+ opts.BasePath.Value = "."
+ }
+
+ if opts.OutFolder.Value == "" {
+ opts.OutFolder.Value = "."
+ }
+
+ if len(opts.Include.Value) == 0 {
+ opts.Include.Value = append(opts.Include.Value, "**")
+ }
+
+ if len(opts.Author.Value) > 0 {
+ if opts.Description.Value == "" {
+ opts.Description.Value = "A deployment package created from files on disk."
+ }
+ }
+
+ return nil
+}
+
+func getReleaseNotesFromFile(filePath string) (string, error) {
+ _, err := os.Stat(filePath)
+ if err != nil {
+ return "", err
+ }
+
+ notes, err := os.ReadFile(filePath)
+ if err != nil {
+ return "", err
+ }
+
+ return string(notes), nil
+}
+
+func shouldGenerateNuSpec(opts *NuPkgCreateOptions) bool {
+ return opts.Description.Value != "" ||
+ opts.Title.Value != "" ||
+ opts.ReleaseNotes.Value != "" ||
+ opts.ReleaseNotesFile.Value != "" ||
+ !util.Empty(opts.Author.Value)
+}
+
+func GenerateNuSpec(opts *NuPkgCreateOptions) (string, error) {
+
+ if opts.Description.Value == "" {
+ return "", errors.New("description is required when generating nuspec metadata")
+ }
+ if len(opts.Author.Value) == 0 {
+ return "", errors.New("at least one author is required when generating nuspec metadata")
+ }
+
+ releaseNotes := opts.ReleaseNotes.Value
+ if opts.ReleaseNotesFile.Value != "" {
+ if releaseNotes != "" {
+ return "", errors.New(`cannot specify both "Nuspec release notes" and "Nuspec release notes file"`)
+ }
+
+ notes, err := getReleaseNotesFromFile(opts.ReleaseNotesFile.Value)
+ releaseNotes = notes
+ if err != nil {
+ return "", err
+ }
+ }
+
+ filePath := filepath.Join(opts.BasePath.Value, opts.Id.Value+".nuspec")
+
+ var sb strings.Builder
+ sb.WriteString(`` + "\n")
+ sb.WriteString(`` + "\n")
+ sb.WriteString(" \n")
+ sb.WriteString(" " + opts.Id.Value + "\n")
+ sb.WriteString(" " + opts.Version.Value + "\n")
+ sb.WriteString(" " + opts.Description.Value + "\n")
+ sb.WriteString(" " + strings.Join(opts.Author.Value, ",") + "\n")
+ if releaseNotes != "" {
+ sb.WriteString(" " + releaseNotes + "\n")
+ }
+ sb.WriteString(" \n")
+ sb.WriteString("\n")
+
+ file, err := os.Create(filePath)
+ if err != nil {
+ return "", err
+ }
+
+ _, err = file.WriteString(sb.String())
+ if err != nil {
+ return "", err
+ }
+
+ return filePath, file.Close()
+}
diff --git a/pkg/cmd/package/nuget/nuget.go b/pkg/cmd/package/nuget/nuget.go
new file mode 100644
index 00000000..8827517b
--- /dev/null
+++ b/pkg/cmd/package/nuget/nuget.go
@@ -0,0 +1,22 @@
+package nuget
+
+import (
+ "github.com/MakeNowJust/heredoc/v2"
+ cmdNugetCreate "github.com/OctopusDeploy/cli/pkg/cmd/package/nuget/create"
+ "github.com/OctopusDeploy/cli/pkg/constants"
+ "github.com/OctopusDeploy/cli/pkg/factory"
+ "github.com/spf13/cobra"
+)
+
+func NewCmdPackageNuget(f factory.Factory) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "nuget ",
+ Short: "Package as NuPkg",
+ Long: "Package as NuPkg for Octopus Deploy",
+ Example: heredoc.Docf("$ %s package nuget create", constants.ExecutableName),
+ }
+
+ cmd.AddCommand(cmdNugetCreate.NewCmdCreate(f))
+
+ return cmd
+}
diff --git a/pkg/cmd/package/package.go b/pkg/cmd/package/package.go
index f089be0e..b43761d0 100644
--- a/pkg/cmd/package/package.go
+++ b/pkg/cmd/package/package.go
@@ -2,10 +2,11 @@ package _package
import (
"fmt"
-
cmdList "github.com/OctopusDeploy/cli/pkg/cmd/package/list"
+ cmdNuget "github.com/OctopusDeploy/cli/pkg/cmd/package/nuget"
cmdUpload "github.com/OctopusDeploy/cli/pkg/cmd/package/upload"
cmdVersions "github.com/OctopusDeploy/cli/pkg/cmd/package/versions"
+ cmdZip "github.com/OctopusDeploy/cli/pkg/cmd/package/zip"
"github.com/OctopusDeploy/cli/pkg/constants"
"github.com/OctopusDeploy/cli/pkg/constants/annotations"
"github.com/OctopusDeploy/cli/pkg/factory"
@@ -26,5 +27,7 @@ func NewCmdPackage(f factory.Factory) *cobra.Command {
cmd.AddCommand(cmdUpload.NewCmdUpload(f))
cmd.AddCommand(cmdList.NewCmdList(f))
cmd.AddCommand(cmdVersions.NewCmdVersions(f))
+ cmd.AddCommand(cmdNuget.NewCmdPackageNuget(f))
+ cmd.AddCommand(cmdZip.NewCmdPackageZip(f))
return cmd
}
diff --git a/pkg/cmd/package/support/pack.go b/pkg/cmd/package/support/pack.go
new file mode 100644
index 00000000..cd323b38
--- /dev/null
+++ b/pkg/cmd/package/support/pack.go
@@ -0,0 +1,270 @@
+package support
+
+import (
+ "archive/zip"
+ "errors"
+ "fmt"
+ "github.com/AlecAivazis/survey/v2"
+ "github.com/OctopusDeploy/cli/pkg/factory"
+ "github.com/OctopusDeploy/cli/pkg/question"
+ "github.com/OctopusDeploy/cli/pkg/util"
+ "github.com/OctopusDeploy/cli/pkg/util/flag"
+ "github.com/bmatcuk/doublestar/v4"
+ "github.com/spf13/cobra"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+const (
+ FlagId = "id"
+ FlagVersion = "version"
+ FlagBasePath = "base-path"
+ FlagOutFolder = "out-folder"
+ FlagInclude = "include"
+ FlagVerbose = "verbose"
+ FlagOverwrite = "overwrite"
+)
+
+type PackageCreateFlags struct {
+ Id *flag.Flag[string]
+ Version *flag.Flag[string]
+ BasePath *flag.Flag[string]
+ OutFolder *flag.Flag[string]
+ Include *flag.Flag[[]string]
+ Verbose *flag.Flag[bool]
+ Overwrite *flag.Flag[bool]
+}
+
+type PackageCreateOptions struct {
+ *PackageCreateFlags
+ Writer io.Writer
+ Ask question.Asker
+ NoPrompt bool
+ CmdPath string
+}
+
+func NewPackageCreateFlags() *PackageCreateFlags {
+ return &PackageCreateFlags{
+ Id: flag.New[string](FlagId, false),
+ Version: flag.New[string](FlagVersion, false),
+ BasePath: flag.New[string](FlagBasePath, false),
+ OutFolder: flag.New[string](FlagOutFolder, false),
+ Include: flag.New[[]string](FlagInclude, false),
+ Verbose: flag.New[bool](FlagVerbose, false),
+ Overwrite: flag.New[bool](FlagOverwrite, false),
+ }
+}
+
+func NewPackageCreateOptions(f factory.Factory, flags *PackageCreateFlags, cmd *cobra.Command) *PackageCreateOptions {
+ return &PackageCreateOptions{
+ PackageCreateFlags: flags,
+ Writer: cmd.OutOrStdout(),
+ Ask: f.Ask,
+ NoPrompt: !f.IsPromptEnabled(),
+ CmdPath: cmd.CommandPath(),
+ }
+}
+
+func PackageCreatePromptMissing(opts *PackageCreateOptions) error {
+ if opts.Id.Value == "" {
+ if err := opts.Ask(&survey.Input{
+ Message: "Package ID",
+ Help: "The ID of the package.",
+ }, &opts.Id.Value, survey.WithValidator(survey.ComposeValidators(
+ survey.Required,
+ survey.MaxLength(200),
+ survey.MinLength(1),
+ ))); err != nil {
+ return err
+ }
+ }
+
+ if opts.Version.Value == "" {
+ if err := opts.Ask(&survey.Input{
+ Message: "Version",
+ Help: "The version of the package, must be a valid SemVer.",
+ }, &opts.Version.Value); err != nil {
+ return err
+ }
+ }
+
+ if opts.BasePath.Value == "" {
+ if err := opts.Ask(&survey.Input{
+ Message: "Base Path",
+ Help: "Root folder containing the contents to zip; defaults to current directory.",
+ Default: ".",
+ }, &opts.BasePath.Value); err != nil {
+ return err
+ }
+ }
+
+ if opts.OutFolder.Value == "" {
+ if err := opts.Ask(&survey.Input{
+ Message: "Out Folder",
+ Help: "Folder into which the zip file will be written; defaults to the current directory.",
+ Default: ".",
+ }, &opts.OutFolder.Value); err != nil {
+ return err
+ }
+ }
+
+ if len(opts.Include.Value) == 0 {
+ for {
+ var pattern string
+ if err := opts.Ask(&survey.Input{
+ Message: "Include patterns",
+ Help: "Add a file pattern to include, relative to the base path e.g. /bin/*.dll; defaults to \"**\" no patterns provided.",
+ }, &pattern); err != nil {
+ return err
+ }
+ if pattern == "" {
+ break
+ }
+ opts.Include.Value = append(opts.Include.Value, pattern)
+ }
+ }
+
+ if !opts.Verbose.Value {
+ err := opts.Ask(&survey.Confirm{
+ Message: "Verbose",
+ Default: true,
+ }, &opts.Verbose.Value)
+ if err != nil {
+ return err
+ }
+ }
+
+ if !opts.Overwrite.Value {
+ err := opts.Ask(&survey.Confirm{
+ Message: "Overwrite",
+ Default: true,
+ }, &opts.Overwrite.Value)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func VerboseOut(out io.Writer, isVerbose bool, messageTemplate string, messageArgs ...any) {
+ if isVerbose {
+ fmt.Fprintf(out, messageTemplate, messageArgs...)
+ }
+}
+
+func BuildTimestampSemVer(dateTime time.Time) string {
+ endSegment := dateTime.Hour()*10000 + dateTime.Minute()*100 + dateTime.Second()
+ return fmt.Sprintf("%d.%d.%d.%d", dateTime.Year(), dateTime.Month(), dateTime.Day(), endSegment)
+}
+
+func BuildOutFileName(packageType, id, version string) string {
+ return fmt.Sprintf("%s.%s.%s", id, version, packageType)
+}
+
+func BuildPackage(opts *PackageCreateOptions, outFileName string) error {
+ outFilePath := filepath.Join(opts.OutFolder.Value, outFileName)
+ outPath, err := filepath.Abs(opts.OutFolder.Value)
+ if err != nil {
+ return err
+ }
+
+ _, err = os.Stat(outFilePath)
+ if !opts.Overwrite.Value && err == nil {
+ return fmt.Errorf("package with name '%s' already exists ...aborting", outFileName)
+ }
+
+ VerboseOut(opts.Writer, opts.Verbose.Value, "Saving \"%s\" to \"%s\"...\nAdding files from \"%s\" matching pattern/s \"%s\"\n", outPath, outFileName, outPath, strings.Join(opts.Include.Value, ", "))
+
+ filePaths, err := getDistinctPatternMatches(opts.BasePath.Value, opts.Include.Value)
+ if err != nil {
+ return err
+ }
+
+ if len(filePaths) == 0 {
+ return errors.New("no files identified to package")
+ }
+
+ return buildArchive(opts.Writer, outFilePath, opts.BasePath.Value, filePaths, opts.Verbose.Value)
+}
+
+func buildArchive(out io.Writer, outFilePath string, basePath string, filesToArchive []string, isVerbose bool) error {
+ _, outFile := filepath.Split(outFilePath)
+ zipFile, err := os.Create(outFilePath)
+ if err != nil {
+ return err
+ }
+ defer zipFile.Close()
+
+ writer := zip.NewWriter(zipFile)
+ defer writer.Close()
+
+ for _, path := range filesToArchive {
+ if path == outFile || path == "." {
+ continue
+ }
+
+ fullPath := filepath.Join(basePath, path)
+ fileInfo, err := os.Stat(fullPath)
+ if err != nil {
+ return err
+ }
+
+ header, err := zip.FileInfoHeader(fileInfo)
+ if err != nil {
+ return err
+ }
+
+ header.Method = zip.Deflate
+ header.Name = path
+ if fileInfo.IsDir() {
+ header.Name += "/"
+ }
+
+ headerWriter, err := writer.CreateHeader(header)
+ if err != nil {
+ return err
+ }
+
+ if fileInfo.IsDir() {
+ continue
+ }
+
+ f, err := os.Open(fullPath)
+ if err != nil {
+ return err
+ }
+
+ _, err = io.Copy(headerWriter, f)
+ if err == nil {
+ VerboseOut(out, isVerbose, "Added file: %s\n", path)
+ } else {
+ return err
+ }
+
+ err = f.Close()
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func getDistinctPatternMatches(basePath string, patterns []string) ([]string, error) {
+ fileSys := os.DirFS(filepath.Clean(basePath))
+ var filePaths []string
+
+ for _, pattern := range patterns {
+ paths, err := doublestar.Glob(fileSys, filepath.ToSlash(pattern))
+ if err != nil {
+ return nil, err
+ }
+ filePaths = append(filePaths, paths...)
+ }
+
+ return util.SliceDistinct(filePaths), nil
+}
diff --git a/pkg/cmd/package/support/pack_test.go b/pkg/cmd/package/support/pack_test.go
new file mode 100644
index 00000000..e7e70068
--- /dev/null
+++ b/pkg/cmd/package/support/pack_test.go
@@ -0,0 +1,69 @@
+package support
+
+import (
+ "errors"
+ "github.com/OctopusDeploy/cli/test/testutil"
+ "github.com/stretchr/testify/assert"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+)
+
+func TestVerboseOut_WithVerboseEnabled(t *testing.T) {
+ result := testutil.CaptureConsoleOutput(func() {
+ VerboseOut(os.Stdout, true, "This %s a %s... %d", "is", "test", 123)
+ })
+ assert.Equal(t, "This is a test... 123", result)
+}
+
+func TestVerboseOut_WithVerboseDisabled(t *testing.T) {
+ result := testutil.CaptureConsoleOutput(func() {
+ VerboseOut(os.Stdout, false, "This %s a %s... %d", "is", "test", 123)
+ })
+ assert.Equal(t, "", result)
+}
+
+func TestBuildTimestampSemVer(t *testing.T) {
+ knownTime := time.Date(2000, time.January, 1, 1, 1, 1, 0, time.UTC)
+ assert.Equal(t, "2000.1.1.10101", BuildTimestampSemVer(knownTime))
+}
+
+func TestBuildOutFileName(t *testing.T) {
+ result := BuildOutFileName("zip", "SomePackage", "1.0.1")
+ assert.Equal(t, "SomePackage.1.0.1.zip", result)
+}
+
+func TestPanicImmediately(t *testing.T) {
+ basePath := setupForArchive(t)
+ if runtime.GOOS == "windows" { // See line 63
+ defer t.Cleanup(func() {
+ cleanUpTemp(basePath)
+ })
+ }
+
+ newPath := filepath.Join(basePath, "test.txt")
+ _, err := os.Stat(newPath)
+ assert.Nil(t, err)
+}
+
+func setupForArchive(t *testing.T) string {
+ dir := filepath.ToSlash(t.TempDir())
+ _, err := os.Create(dir + "/test.txt")
+ if err != nil {
+ panic(err)
+ }
+
+ return dir
+}
+
+// TODO Test and potentially remove manual clean-up when go version >= 1.20.0
+// cleanUpTemp is a temporary solution for windows to https://github.com/golang/go/issues/51442.
+func cleanUpTemp(tempDir string) {
+ err := errors.New("init not nil")
+ for err != nil {
+ time.Sleep(time.Millisecond * 10)
+ err = os.RemoveAll(tempDir)
+ }
+}
diff --git a/pkg/cmd/package/zip/create/create.go b/pkg/cmd/package/zip/create/create.go
new file mode 100644
index 00000000..afd8bf85
--- /dev/null
+++ b/pkg/cmd/package/zip/create/create.go
@@ -0,0 +1,93 @@
+package create
+
+import (
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/MakeNowJust/heredoc/v2"
+ pack "github.com/OctopusDeploy/cli/pkg/cmd/package/support"
+ "github.com/OctopusDeploy/cli/pkg/constants"
+ "github.com/OctopusDeploy/cli/pkg/factory"
+ "github.com/OctopusDeploy/cli/pkg/util/flag"
+ "github.com/spf13/cobra"
+)
+
+func NewCmdCreate(f factory.Factory) *cobra.Command {
+ createFlags := pack.NewPackageCreateFlags()
+
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create zip",
+ Long: "Create zip package",
+ Example: heredoc.Docf(`
+ $ %[1]s package zip create --id SomePackage --version 1.0.0
+ `, constants.ExecutableName),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ opts := pack.NewPackageCreateOptions(f, createFlags, cmd)
+ return createRun(opts)
+ },
+ }
+
+ flags := cmd.Flags()
+ flags.StringVar(&createFlags.Id.Value, createFlags.Id.Name, "", "The ID of the package.")
+ flags.StringVarP(&createFlags.Version.Value, createFlags.Version.Name, "v", "", "The version of the package, must be a valid SemVer.")
+ flags.StringVar(&createFlags.BasePath.Value, createFlags.BasePath.Name, "", "Root folder containing the contents to zip.")
+ flags.StringVar(&createFlags.OutFolder.Value, createFlags.OutFolder.Name, "", "Folder into which the zip file will be written.")
+ flags.StringSliceVar(&createFlags.Include.Value, createFlags.Include.Name, []string{}, "Add a file pattern to include, relative to the base path e.g. /bin/*.dll; defaults to \"**\".")
+ flags.BoolVar(&createFlags.Verbose.Value, createFlags.Verbose.Name, false, "Verbose output.")
+ flags.BoolVar(&createFlags.Overwrite.Value, createFlags.Overwrite.Name, false, "Allow an existing package file of the same ID/version to be overwritten.")
+ flags.SortFlags = false
+
+ return cmd
+}
+
+func createRun(opts *pack.PackageCreateOptions) error {
+ if !opts.NoPrompt {
+ if err := pack.PackageCreatePromptMissing(opts); err != nil {
+ return err
+ }
+ }
+
+ if opts.Id.Value == "" {
+ return errors.New("must supply a package ID")
+ }
+ applyDefaultsToUnspecifiedOptions(opts)
+
+ pack.VerboseOut(opts.Writer, opts.Verbose.Value, "Packing \"%s\" version \"%s\"...\n", opts.Id.Value, opts.Version.Value)
+
+ if !opts.NoPrompt {
+ autoCmd := flag.GenerateAutomationCmd(
+ opts.CmdPath,
+ opts.Id,
+ opts.Version,
+ opts.BasePath,
+ opts.OutFolder,
+ opts.Include,
+ opts.Verbose,
+ opts.Overwrite,
+ )
+ fmt.Fprintf(opts.Writer, "\nAutomation Command: %s\n", autoCmd)
+ }
+
+ outFilePath := pack.BuildOutFileName("zip", opts.Id.Value, opts.Version.Value)
+ return pack.BuildPackage(opts, outFilePath)
+}
+
+func applyDefaultsToUnspecifiedOptions(opts *pack.PackageCreateOptions) {
+ if opts.Version.Value == "" {
+ opts.Version.Value = pack.BuildTimestampSemVer(time.Now())
+ }
+
+ if opts.BasePath.Value == "" {
+ opts.BasePath.Value = "."
+ }
+
+ if opts.OutFolder.Value == "" {
+ opts.OutFolder.Value = "."
+ }
+
+ if len(opts.Include.Value) == 0 {
+ opts.Include.Value = append(opts.Include.Value, "**")
+ }
+}
diff --git a/pkg/cmd/package/zip/zip.go b/pkg/cmd/package/zip/zip.go
new file mode 100644
index 00000000..c55e20e7
--- /dev/null
+++ b/pkg/cmd/package/zip/zip.go
@@ -0,0 +1,22 @@
+package zip
+
+import (
+ "github.com/MakeNowJust/heredoc/v2"
+ cmdZipCreate "github.com/OctopusDeploy/cli/pkg/cmd/package/zip/create"
+ "github.com/OctopusDeploy/cli/pkg/constants"
+ "github.com/OctopusDeploy/cli/pkg/factory"
+ "github.com/spf13/cobra"
+)
+
+func NewCmdPackageZip(f factory.Factory) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "zip ",
+ Short: "Package as zip",
+ Long: "Package as zip for Octopus Deploy",
+ Example: heredoc.Docf("$ %s package zip create", constants.ExecutableName),
+ }
+
+ cmd.AddCommand(cmdZipCreate.NewCmdCreate(f))
+
+ return cmd
+}
diff --git a/pkg/cmd/target/azure-web-app/create/create.go b/pkg/cmd/target/azure-web-app/create/create.go
index 7618b159..302e5473 100644
--- a/pkg/cmd/target/azure-web-app/create/create.go
+++ b/pkg/cmd/target/azure-web-app/create/create.go
@@ -150,7 +150,7 @@ func createRun(opts *CreateOptions) error {
endpoint.WebAppName = opts.WebApp.Value
endpoint.ResourceGroupName = opts.ResourceGroup.Value
endpoint.WebAppSlotName = opts.Slot.Value
- deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.DistinctStrings(opts.Roles.Value))
+ deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value))
err = shared.ConfigureTenant(deploymentTarget, opts.CreateTargetTenantFlags, opts.CreateTargetTenantOptions)
if err != nil {
diff --git a/pkg/cmd/target/cloud-region/create/create.go b/pkg/cmd/target/cloud-region/create/create.go
index 7ab72eda..e358ecd0 100644
--- a/pkg/cmd/target/cloud-region/create/create.go
+++ b/pkg/cmd/target/cloud-region/create/create.go
@@ -111,7 +111,7 @@ func createRun(opts *CreateOptions) error {
endpoint.DefaultWorkerPoolID = workerPoolId
}
- target := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.DistinctStrings(opts.Roles.Value))
+ target := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value))
err = shared.ConfigureTenant(target, opts.CreateTargetTenantFlags, opts.CreateTargetTenantOptions)
if err != nil {
return err
diff --git a/pkg/cmd/target/kubernetes/create/create.go b/pkg/cmd/target/kubernetes/create/create.go
index 7eec5ae1..fee93fe6 100644
--- a/pkg/cmd/target/kubernetes/create/create.go
+++ b/pkg/cmd/target/kubernetes/create/create.go
@@ -446,7 +446,7 @@ func (opts *CreateOptions) Commit() error {
endpoint.Authentication = auth
}
- deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.DistinctStrings(opts.Roles.Value))
+ deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value))
machinePolicy, err := machinescommon.FindDefaultMachinePolicy(opts.GetAllMachinePoliciesCallback)
if err != nil {
diff --git a/pkg/cmd/target/listening-tentacle/create/create.go b/pkg/cmd/target/listening-tentacle/create/create.go
index f06f0e2f..3745fec1 100644
--- a/pkg/cmd/target/listening-tentacle/create/create.go
+++ b/pkg/cmd/target/listening-tentacle/create/create.go
@@ -131,7 +131,7 @@ func createRun(opts *CreateOptions) error {
endpoint.ProxyID = proxy.GetID()
}
- deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.DistinctStrings(opts.Roles.Value))
+ deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value))
machinePolicy, err := machinescommon.FindMachinePolicy(opts.GetAllMachinePoliciesCallback, opts.MachinePolicy.Value)
if err != nil {
return err
diff --git a/pkg/cmd/target/shared/role_test.go b/pkg/cmd/target/shared/role_test.go
index 1b8f0a36..01c842ac 100644
--- a/pkg/cmd/target/shared/role_test.go
+++ b/pkg/cmd/target/shared/role_test.go
@@ -3,11 +3,22 @@ package shared_test
import (
"github.com/OctopusDeploy/cli/pkg/cmd"
"github.com/OctopusDeploy/cli/pkg/cmd/target/shared"
+ "github.com/OctopusDeploy/cli/pkg/util"
"github.com/OctopusDeploy/cli/test/testutil"
"github.com/stretchr/testify/assert"
"testing"
)
+func TestDistinctRoles_EmptyList(t *testing.T) {
+ result := util.SliceDistinct([]string{})
+ assert.Empty(t, result)
+}
+
+func TestDistinctRoles_DuplicateValues(t *testing.T) {
+ result := util.SliceDistinct([]string{"a", "b", "a"})
+ assert.Equal(t, []string{"a", "b"}, result)
+}
+
func TestPromptRoles_FlagsSupplied(t *testing.T) {
pa := []*testutil.PA{}
diff --git a/pkg/cmd/target/ssh/create/create.go b/pkg/cmd/target/ssh/create/create.go
index a13c84c0..9ea153d4 100644
--- a/pkg/cmd/target/ssh/create/create.go
+++ b/pkg/cmd/target/ssh/create/create.go
@@ -141,7 +141,7 @@ func createRun(opts *CreateOptions) error {
endpoint.ProxyID = proxy.GetID()
}
- deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.DistinctStrings(opts.Roles.Value))
+ deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value))
machinePolicy, err := machinescommon.FindMachinePolicy(opts.GetAllMachinePoliciesCallback, opts.MachinePolicy.Value)
if err != nil {
return err
diff --git a/pkg/cmd/tenant/list/list.go b/pkg/cmd/tenant/list/list.go
index cccac7b5..b5e0cbc1 100644
--- a/pkg/cmd/tenant/list/list.go
+++ b/pkg/cmd/tenant/list/list.go
@@ -112,7 +112,7 @@ func getEnvironmentMap(client *client.Client, tenants []*tenants.Tenant) (map[st
}
}
- environmentIds = util.DistinctStrings(environmentIds)
+ environmentIds = util.SliceDistinct(environmentIds)
environmentMap := make(map[string]string)
queryResult, err := client.Environments.Get(environments.EnvironmentsQuery{IDs: environmentIds})
@@ -140,7 +140,7 @@ func getProjectMap(client *client.Client, tenants []*tenants.Tenant) (map[string
projectIds = append(projectIds, p)
}
}
- projectIds = util.DistinctStrings(projectIds)
+ projectIds = util.SliceDistinct(projectIds)
projectMap := make(map[string]string)
queryResult, err := client.Projects.Get(projects.ProjectsQuery{IDs: projectIds})
diff --git a/pkg/util/util.go b/pkg/util/util.go
index e8b4133f..96d3c40a 100644
--- a/pkg/util/util.go
+++ b/pkg/util/util.go
@@ -165,15 +165,14 @@ func Any[T any](items []T) bool {
return !Empty(items)
}
-func DistinctStrings(items []string) []string {
- itemsMap := make(map[string]bool)
- result := []string{}
- for _, r := range items {
- if _, ok := itemsMap[r]; !ok {
- itemsMap[r] = true
- result = append(result, r)
+func SliceDistinct[T comparable](slice []T) []T {
+ inResult := make(map[T]bool)
+ var result []T
+ for _, str := range slice {
+ if _, ok := inResult[str]; !ok {
+ inResult[str] = true
+ result = append(result, str)
}
}
-
return result
}
diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go
index 272f640e..5a33ae29 100644
--- a/pkg/util/util_test.go
+++ b/pkg/util/util_test.go
@@ -390,12 +390,16 @@ func TestEmpty_SomeItems(t *testing.T) {
assert.False(t, util.Empty([]string{"value"}))
}
-func TestDistinctStrings_EmptyList(t *testing.T) {
- result := util.DistinctStrings([]string{})
- assert.Empty(t, result)
+func TestDistinct_Empty(t *testing.T) {
+ assert.Equal(t, []string(nil), util.SliceDistinct([]string{}))
}
-func TestDistinctStrings_DuplicateValues(t *testing.T) {
- result := util.DistinctStrings([]string{"a", "b", "a"})
- assert.Equal(t, []string{"a", "b"}, result)
+func TestDistinct_WithoutDuplicateItems(t *testing.T) {
+ items := []string{"foo", "bar", "baz"}
+ assert.Equal(t, items, util.SliceDistinct(items))
+}
+
+func TestDistinct_WithDuplicateItems(t *testing.T) {
+ items := []string{"foo", "bar", "foo", "baz", "bar"}
+ assert.Equal(t, []string{"foo", "bar", "baz"}, util.SliceDistinct(items))
}
diff --git a/test/testutil/testutil.go b/test/testutil/testutil.go
index 581709a1..640e254e 100644
--- a/test/testutil/testutil.go
+++ b/test/testutil/testutil.go
@@ -1,10 +1,12 @@
package testutil
import (
+ "bytes"
"encoding/json"
"errors"
"io"
"net/http"
+ "os"
"runtime/debug"
"testing"
)
@@ -105,3 +107,32 @@ func ParseJsonStrict[T any](input io.Reader) (T, error) {
decoder.DisallowUnknownFields()
return parsedStdout, decoder.Decode(&parsedStdout)
}
+
+// CaptureConsoleOutput borrows from `github.com/zenizh/go-capturer`. This implementation captures both stdout
+// and stderr. Consider adding the go-capturer module if more granularity is needed
+func CaptureConsoleOutput(f func()) string {
+ r, w, err := os.Pipe()
+ if err != nil {
+ panic(err)
+ }
+
+ stdout := os.Stdout
+ os.Stdout = w
+ defer func() {
+ os.Stdout = stdout
+ }()
+
+ stderr := os.Stderr
+ os.Stderr = w
+ defer func() {
+ os.Stderr = stderr
+ }()
+
+ f()
+ w.Close()
+
+ var buf bytes.Buffer
+ io.Copy(&buf, r)
+
+ return buf.String()
+}