diff --git a/install.ps1 b/install.ps1 index 0bcd8ac..7abc6ea 100644 --- a/install.ps1 +++ b/install.ps1 @@ -28,6 +28,6 @@ Invoke-WebRequest -Uri $downloadUrl -OutFile "$binaryName.exe" # Install the binary Write-Host "Installing $binaryName to $installDir..." -Move-Item -Path "$binaryName.exe" -Destination $installDir +Move-Item -Path "$binaryName.exe" -Destination $installDir -Force Write-Host "Installation complete!" diff --git a/internal/archive/targz.go b/internal/archive/targz.go index 66692e4..6664b31 100644 --- a/internal/archive/targz.go +++ b/internal/archive/targz.go @@ -47,7 +47,7 @@ func shouldIgnoreFilepath(filepath string, isDir bool, matchers []gitignore.Igno return anyMatches } -func ArchiveTGZ(srcFolder string) (string, error) { +func CreateTGZ(srcFolder string) (string, error) { fileName := filepath.Base(filepath.Clean(srcFolder)) destinationFile := fmt.Sprintf("%s.*.tgz", fileName) tarGzFile, err := os.CreateTemp("", destinationFile) @@ -82,21 +82,22 @@ func ArchiveTGZ(srcFolder string) (string, error) { } if shouldIgnoreFilepath(relPath, info.IsDir(), ignoreMatchers) { + zap.L().Debug("Ignoring file: " + relPath) return nil } - header, err := tar.FileInfoHeader(info, relPath) + header, err := tar.FileInfoHeader(info, "") if err != nil { return err } - - header.Name = relPath + header.Name = filepath.ToSlash(relPath) if err := tarWriter.WriteHeader(header); err != nil { return err } if info.IsDir() { + zap.L().Debug("Including directory reference: " + relPath) return nil } @@ -106,10 +107,15 @@ func ArchiveTGZ(srcFolder string) (string, error) { } defer file.Close() - if _, err := io.Copy(tarWriter, file); err != nil { - return err + written, err := io.Copy(tarWriter, file) + if err != nil { + return fmt.Errorf("error copying file content for %s: %w", filePath, err) + } + if written != info.Size() { + return fmt.Errorf("expected to write %d bytes but wrote %d bytes for file %s", info.Size(), written, filePath) } + zap.L().Debug("Inlcluding file: " + relPath) return nil }) @@ -181,7 +187,7 @@ func RequireTGZ(srcFolder string) (*TGZFile, error) { zap.L().Debug(srcFolder + " is not a gzipped tar archive. Archiving and compressing now.") - destFile, err := ArchiveTGZ(srcFolder) + destFile, err := CreateTGZ(srcFolder) if err != nil { return nil, err } diff --git a/internal/archive/targz_test.go b/internal/archive/targz_test.go new file mode 100644 index 0000000..5726864 --- /dev/null +++ b/internal/archive/targz_test.go @@ -0,0 +1,139 @@ +package archive_test + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/hathora/ci/internal/archive" +) + +func Test_CreateTGZ(t *testing.T) { + zapLogger, _ := zap.NewDevelopment() + zap.ReplaceGlobals(zapLogger) + tests := []struct { + name string + files map[string]string + shouldFail bool + }{ + { + name: "simple", + files: map[string]string{ + "file1.txt": "This is file 1", + "subdir/file2.txt": "This is file 2 in subdir", + }, + shouldFail: false, + }, + { + name: "nested", + files: map[string]string{ + "dir1/dir2/file3.txt": "This is file 3 in nested directory", + "dir1/file4.txt": "This is file 4", + }, + shouldFail: false, + }, + { + name: "nested with dirs only", + files: map[string]string{ + "dir3/": "", + "dir3/dir4/": "", + "dir3/dir4/file5.txt": "This is file 5 in nested empty directory", + }, + shouldFail: false, + }, + { + name: "special characters in filenames", + files: map[string]string{ + "file with spaces.txt": "This is a file with spaces", + "file-with-üñîçødé.txt": "This file has unicode characters", + }, + shouldFail: false, + }, + { + name: "empty", + files: map[string]string{}, + shouldFail: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert := assert.New(t) + + srcFolder, err := os.MkdirTemp("", "testsrc") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(srcFolder) + }) + + for path, content := range tt.files { + fullPath := filepath.Join(srcFolder, path) + if strings.HasSuffix(path, "/") { + require.NoError(t, os.MkdirAll(fullPath, os.ModePerm)) + } else { + err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm) + require.NoError(t, err) + err = os.WriteFile(fullPath, []byte(content), 0644) + require.NoError(t, err) + } + } + + archivePath, err := archive.CreateTGZ(srcFolder) + if tt.shouldFail { + assert.Error(err) + return + } else { + assert.NoError(err) + } + t.Cleanup(func() { + os.Remove(archivePath) + }) + + file, err := os.Open(archivePath) + assert.NoError(err) + t.Cleanup(func() { + file.Close() + }) + + gzipReader, err := gzip.NewReader(file) + assert.NoError(err) + t.Cleanup(func() { + gzipReader.Close() + }) + + tarReader := tar.NewReader(gzipReader) + + archivedFiles := make(map[string]string) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + assert.NoError(err) + + if header.Typeflag == tar.TypeReg { + content, err := io.ReadAll(tarReader) + assert.NoError(err) + archivedFiles[header.Name] = string(content) + } + } + + for path, expectedContent := range tt.files { + if strings.HasSuffix(path, "/") { + continue // Skip directories + } + content, found := archivedFiles[path] + assert.True(found, "Expected file %s not found in archive", path) + assert.Equal(expectedContent, content, "File content mismatch for %s", path) + } + }) + } +} diff --git a/internal/commands/app.go b/internal/commands/app.go index e09a4b5..2265e11 100644 --- a/internal/commands/app.go +++ b/internal/commands/app.go @@ -14,22 +14,33 @@ var ( BuildVersion = "unknown" ) -func App() *cli.Command { +func init() { cli.VersionFlag = &cli.BoolFlag{ - Name: "version", - Usage: "print the version", + Name: "version", + Usage: "print the version", + Category: "Global:", } + cli.HelpFlag = &cli.BoolFlag{ + Name: "help", + Aliases: []string{"h"}, + Usage: "show help", + Category: "Global:", + } +} + +func App() *cli.Command { var cleanup []func() return &cli.Command{ - Name: "hathora", - EnableShellCompletion: true, - Suggest: true, - UseShortOptionHandling: true, - SliceFlagSeparator: ",", - Usage: "a CLI tool for for CI/CD workflows to manage deployments and builds in hathora.dev", - Flags: GlobalFlags, - Version: BuildVersion, + Name: "hathora", + EnableShellCompletion: true, + Suggest: true, + UseShortOptionHandling: true, + SliceFlagSeparator: ",", + Usage: "a CLI tool for for CI/CD workflows to manage deployments and builds in hathora.dev", + Flags: GlobalFlags, + Version: BuildVersion, + CustomRootCommandHelpTemplate: cli.SubcommandHelpTemplate, Before: func(ctx context.Context, cmd *cli.Command) error { handleNewVersionAvailable(BuildVersion) @@ -40,7 +51,7 @@ func App() *cli.Command { if err != nil { return err } - cfg, err := GlobalConfigFrom(cmd) + cfg, err := VerbosityConfigFrom(cmd) if err != nil { return err } diff --git a/internal/commands/build.go b/internal/commands/build.go index 64a09c0..351851f 100644 --- a/internal/commands/build.go +++ b/internal/commands/build.go @@ -82,7 +82,7 @@ var Build = &cli.Command{ cli.ShowSubcommandHelp(cmd) return err } - created, err := doBuildCreate(ctx, build) + created, err := doBuildCreate(ctx, build.SDK, build.AppID, build.BuildTag, build.FilePath) if err != nil { return err } @@ -119,24 +119,24 @@ var Build = &cli.Command{ }, } -func doBuildCreate(ctx context.Context, build *CreateBuildConfig) (*shared.Build, error) { - createRes, err := build.SDK.BuildV2.CreateBuild( +func doBuildCreate(ctx context.Context, hathora *sdk.SDK, appID *string, buildTag, filePath string) (*shared.Build, error) { + createRes, err := hathora.BuildV2.CreateBuild( ctx, shared.CreateBuildParams{ - BuildTag: sdk.String(build.BuildTag), + BuildTag: sdk.String(buildTag), }, - build.AppID, + appID, ) if err != nil { return nil, fmt.Errorf("failed to create a build: %w", err) } - file, err := archive.RequireTGZ(build.FilePath) + file, err := archive.RequireTGZ(filePath) if err != nil { return nil, fmt.Errorf("no build file available for run: %w", err) } - runRes, err := build.SDK.BuildV2.RunBuild( + runRes, err := hathora.BuildV2.RunBuild( ctx, createRes.Build.BuildID, operations.RunBuildRequestBody{ @@ -145,7 +145,7 @@ func doBuildCreate(ctx context.Context, build *CreateBuildConfig) (*shared.Build Content: file.Content, }, }, - build.AppID, + appID, ) if err != nil { @@ -211,7 +211,8 @@ var ( type BuildConfig struct { *GlobalConfig - SDK *sdk.SDK + SDK *sdk.SDK + Output output.FormatWriter } var _ LoadableConfig = (*BuildConfig)(nil) @@ -223,6 +224,12 @@ func (c *BuildConfig) Load(cmd *cli.Command) error { } c.GlobalConfig = global c.SDK = setup.SDK(c.Token, c.BaseURL, c.Verbosity) + var build shared.Build + output, err := OutputFormatterFor(cmd, build) + if err != nil { + return err + } + c.Output = output return nil } diff --git a/internal/commands/common.go b/internal/commands/common.go index 9456f4b..ba963e7 100644 --- a/internal/commands/common.go +++ b/internal/commands/common.go @@ -2,6 +2,7 @@ package commands import ( "encoding/json" + "errors" "fmt" "go/version" "math" @@ -58,48 +59,67 @@ func ConfigFromCLI[T LoadableConfig](key string, cmd *cli.Command) (T, error) { return cfg, nil } -type GlobalConfig struct { - Token string - BaseURL string - AppID *string - Output output.FormatWriter +type VerbosityConfig struct { Verbosity int Log *zap.Logger } +func (c *VerbosityConfig) Load(cmd *cli.Command) error { + // we subtract 1 because the flag is counted an additional time for the + // --verbose alias + verboseCount := cmd.Count(verboseFlag.Name) - 1 + verbosity := cmd.Int(verbosityFlag.Name) + c.Verbosity = int(math.Max(float64(verbosity), float64(verboseCount))) + return nil +} + +func (c *VerbosityConfig) New() LoadableConfig { + return &VerbosityConfig{} +} + +var ( + verbosityConfigKey = "commands.VerbosityConfig.DI" +) + +func VerbosityConfigFrom(cmd *cli.Command) (*VerbosityConfig, error) { + cfg, err := ConfigFromCLI[*VerbosityConfig](verbosityConfigKey, cmd) + if err != nil { + return nil, err + } + return cfg, nil +} + +type GlobalConfig struct { + *VerbosityConfig + Token string + BaseURL string + AppID *string +} + func (c *GlobalConfig) Load(cmd *cli.Command) error { + verbosityConfig, err := VerbosityConfigFrom(cmd) + if err != nil { + return err + } + c.VerbosityConfig = verbosityConfig c.Token = cmd.String(tokenFlag.Name) + if c.Token == "" { + err = errors.Join(err, missingRequiredFlag(tokenFlag.Name)) + } c.BaseURL = cmd.String(hathoraCloudEndpointFlag.Name) + if c.BaseURL == "" { + err = errors.Join(err, missingRequiredFlag(hathoraCloudEndpointFlag.Name)) + } + appID := cmd.String(appIDFlag.Name) if appID == "" { - c.AppID = nil + err = errors.Join(err, missingRequiredFlag(appIDFlag.Name)) } else { c.AppID = &appID } - - outputType := cmd.String(outputTypeFlag.Name) - switch output.ParseOutputType(outputType) { - case output.JSON: - c.Output = output.JSONFormat(cmd.Bool(outputPrettyFlag.Name)) - case output.Text: - c.Output = BuildTextFormatter() - case output.Value: - splitValue := strings.Split(outputType, "=") - if len(splitValue) != 2 { - return fmt.Errorf("invalid value format: %s", outputType) - } - c.Output = output.ValueFormat(splitValue[1]) - default: - return fmt.Errorf("unsupported output type: %s", outputType) - } - - // we subtract 1 because the flag is counted an additional time for the - // --verbose alias - verboseCount := cmd.Count(verboseFlag.Name) - 1 - verbosity := cmd.Int(verbosityFlag.Name) - c.Verbosity = int(math.Max(float64(verbosity), float64(verboseCount))) c.Log = zap.L().With(zap.String("app.id", appID)) - return nil + + return err } func (c *GlobalConfig) New() LoadableConfig { @@ -120,13 +140,31 @@ func GlobalConfigFrom(cmd *cli.Command) (*GlobalConfig, error) { func isCallForHelp(cmd *cli.Command) bool { for _, arg := range cmd.Args().Slice() { - if arg == "--help" || arg == "-h" { + if arg == "--help" || arg == "-h" || arg == "help" { return true } } return false } +func OutputFormatterFor(cmd *cli.Command, outputType any) (output.FormatWriter, error) { + outputFmt := cmd.String(outputFlag.Name) + switch output.ParseOutputType(outputFmt) { + case output.JSON: + return output.JSONFormat(cmd.Bool(outputPrettyFlag.Name)), nil + case output.Text: + return BuildTextFormatter(), nil + case output.Value: + fieldName := strings.TrimSuffix(outputFmt, "Value") + if len(fieldName) == 0 { + return nil, fmt.Errorf("invalid value format: %s", outputType) + } + return output.ValueFormat(outputType, fieldName) + default: + return nil, fmt.Errorf("unsupported output type: %s", outputType) + } +} + func BuildTextFormatter() output.FormatWriter { // TODO: Allow commands to register their own formatters so that this one function doesn't have to know the desired format for every type var build shared.Build diff --git a/internal/commands/deploy.go b/internal/commands/deploy.go index 5566838..3341bb8 100644 --- a/internal/commands/deploy.go +++ b/internal/commands/deploy.go @@ -6,10 +6,10 @@ import ( "fmt" "os" - "github.com/hathora/ci/internal/sdk" "github.com/hathora/ci/internal/sdk/models/shared" "github.com/hathora/ci/internal/shorthand" "github.com/urfave/cli/v3" + "go.uber.org/zap" ) var Deploy = &cli.Command{ @@ -53,7 +53,7 @@ var Deploy = &cli.Command{ return err } - createdBuild, err := doBuildCreate(ctx, deploy.CreateBuildConfig) + createdBuild, err := doBuildCreate(ctx, deploy.SDK, deploy.AppID, deploy.BuildTag, deploy.FilePath) if err != nil { return err } @@ -86,46 +86,23 @@ var ( ) type DeployConfig struct { - *CreateBuildConfig - IdleTimeoutEnabled *bool - RoomsPerProcess int - TransportType shared.TransportType - ContainerPort int - RequestedMemoryMB float64 - RequestedCPU float64 - AdditionalContainerPorts []shared.ContainerPort - Env []shared.DeploymentConfigV2Env + *CreateDeploymentConfig + BuildTag string + FilePath string } var _ LoadableConfig = (*DeployConfig)(nil) func (c *DeployConfig) Load(cmd *cli.Command) error { - build, err := CreateBuildConfigFrom(cmd) + deployment, err := CreateDeploymentConfigFrom(cmd) if err != nil { return err } - c.CreateBuildConfig = build + c.CreateDeploymentConfig = deployment - c.RoomsPerProcess = int(cmd.Int(roomsPerProcessFlag.Name)) - c.TransportType = shared.TransportType(cmd.String(transportTypeFlag.Name)) - c.ContainerPort = int(cmd.Int(containerPortFlag.Name)) - c.RequestedMemoryMB = cmd.Float(requestedMemoryFlag.Name) - c.RequestedCPU = cmd.Float(requestedCPUFlag.Name) - c.IdleTimeoutEnabled = sdk.Bool(cmd.Bool(idleTimeoutFlag.Name)) - - addlPorts := cmd.StringSlice(additionalContainerPortsFlag.Name) - parsedAddlPorts, err := parseContainerPorts(addlPorts) - if err != nil { - return fmt.Errorf("invalid additional container ports: %w", err) - } - c.AdditionalContainerPorts = parsedAddlPorts - - envVars := cmd.StringSlice(envVarsFlag.Name) - env, err := parseEnvVars(envVars) - if err != nil { - return fmt.Errorf("invalid environment variables: %w", err) - } - c.Env = env + c.BuildTag = cmd.String(buildTagFlag.Name) + c.FilePath = cmd.String(fileFlag.Name) + c.Log = c.Log.With(zap.String("build.tag", c.BuildTag)) return nil } diff --git a/internal/commands/deployment.go b/internal/commands/deployment.go index ce41c12..3759f76 100644 --- a/internal/commands/deployment.go +++ b/internal/commands/deployment.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "github.com/hathora/ci/internal/commands/altsrc" + "github.com/hathora/ci/internal/output" "github.com/hathora/ci/internal/sdk" "github.com/hathora/ci/internal/sdk/models/shared" "github.com/hathora/ci/internal/setup" @@ -313,7 +314,8 @@ var ( type DeploymentConfig struct { *GlobalConfig - SDK *sdk.SDK + SDK *sdk.SDK + Output output.FormatWriter } var _ LoadableConfig = (*DeploymentConfig)(nil) @@ -324,7 +326,14 @@ func (c *DeploymentConfig) Load(cmd *cli.Command) error { return err } c.GlobalConfig = global + c.SDK = setup.SDK(c.Token, c.BaseURL, c.Verbosity) + var deployment shared.DeploymentV2 + output, err := OutputFormatterFor(cmd, deployment) + if err != nil { + return err + } + c.Output = output return nil } diff --git a/internal/commands/flags.go b/internal/commands/flags.go index bd515c0..c06115d 100644 --- a/internal/commands/flags.go +++ b/internal/commands/flags.go @@ -7,17 +7,18 @@ import ( ) var ( - outputTypeFlag = &cli.StringFlag{ + outputFlag = &cli.StringFlag{ Name: "output", Aliases: []string{"o"}, Sources: cli.NewValueSourceChain( cli.EnvVar(globalFlagEnvVar("OUTPUT")), altsrc.ConfigFile(configFlag.Name, "global.output"), ), - Usage: "the `` of the output. Supported values: (json, text, value=buildId)", - Value: "text", - Persistent: true, - Category: "Global:", + Usage: "the `` of the output. Supported values: (json, text, buildIdValue)", + Value: "text", + DefaultText: "text", + Persistent: true, + Category: "Global:", } outputPrettyFlag = &cli.BoolFlag{ @@ -38,7 +39,6 @@ var ( Usage: "the `` of the app in Hathora", Category: "Global:", Persistent: true, - Required: true, } verboseFlag = &cli.BoolFlag{ @@ -68,6 +68,7 @@ var ( altsrc.ConfigFile(configFlag.Name, "global.cloud-endpoint"), ), Usage: "override the default API base ``", + Value: "https://api.hathora.dev", DefaultText: "https://api.hathora.dev", Category: "Global:", Persistent: true, @@ -80,7 +81,6 @@ var ( Usage: "`` for authenticating with the API", Category: "Global:", Persistent: true, - Required: true, } configFlag = &cli.StringFlag{ @@ -95,7 +95,7 @@ var ( appIDFlag, hathoraCloudEndpointFlag, tokenFlag, - outputTypeFlag, + outputFlag, outputPrettyFlag, verboseFlag, verbosityFlag, diff --git a/internal/output/text.go b/internal/output/text.go index f7a1956..1a52f90 100644 --- a/internal/output/text.go +++ b/internal/output/text.go @@ -266,6 +266,32 @@ func (t *textOutputWriter) printFieldValue(parentType, propertyName string, v re return nil } + switch v.Kind() { + case reflect.Slice: + for i := 0; i < v.Len(); i++ { + if i > 0 { + fmt.Fprintf(writer, ",") + } + propertyName = fmt.Sprintf("%s[%d]", propertyName, i) + err := t.printFieldValue(parentType, propertyName, v.Index(i), writer) + if err != nil { + return err + } + } + case reflect.Ptr: + if v.IsNil() { + fmt.Fprintf(writer, "null") + } else { + return t.printFieldValue(parentType, propertyName, v.Elem(), writer) + } + default: + return printValue(v, writer) + } + + return nil +} + +func printValue(v reflect.Value, writer io.Writer) error { switch v.Kind() { case reflect.String: fmt.Fprintf(writer, "%s", v.String()) @@ -282,15 +308,13 @@ func (t *textOutputWriter) printFieldValue(parentType, propertyName string, v re if i > 0 { fmt.Fprintf(writer, ",") } - elementFieldName := fmt.Sprintf("%s[%d]", propertyName, i) - err := t.printFieldValue(parentType, elementFieldName, v.Index(i), writer) + err := printValue(v.Index(i), writer) if err != nil { return err } } case reflect.Struct: fmt.Fprintf(writer, "{") - typeName := v.Type().String() for i := 0; i < v.NumField(); i++ { if i > 0 { fmt.Fprintf(writer, ",") @@ -298,7 +322,7 @@ func (t *textOutputWriter) printFieldValue(parentType, propertyName string, v re name := v.Type().Field(i).Name fmt.Fprintf(writer, "%s:", name) field := v.Field(i) - err := t.printFieldValue(typeName, propertyName, field, writer) + err := printValue(field, writer) if err != nil { return err } @@ -308,7 +332,7 @@ func (t *textOutputWriter) printFieldValue(parentType, propertyName string, v re if v.IsNil() { fmt.Fprintf(writer, "null") } else { - return t.printFieldValue(parentType, propertyName, v.Elem(), writer) + return printValue(v.Elem(), writer) } default: if stringer, ok := v.Interface().(fmt.Stringer); ok { diff --git a/internal/output/types.go b/internal/output/types.go index 188d6ca..ae18f1c 100644 --- a/internal/output/types.go +++ b/internal/output/types.go @@ -24,7 +24,7 @@ func (o Type) String() string { func ParseOutputType(s string) Type { lowercaseOutputType := strings.ToLower(s) - if strings.HasPrefix(lowercaseOutputType, "value=") { + if strings.HasSuffix(lowercaseOutputType, "value") { return Value } switch lowercaseOutputType { diff --git a/internal/output/value.go b/internal/output/value.go index 06daee3..63827c1 100644 --- a/internal/output/value.go +++ b/internal/output/value.go @@ -3,74 +3,51 @@ package output import ( "fmt" "io" + "reflect" "strings" - - "github.com/hathora/ci/internal/sdk/models/shared" ) -func ValueFormat(field string) FormatWriter { - return &valueOutputWriter{ - Field: field, +func ValueFormat(empty any, field string) (FormatWriter, error) { + tType := reflect.TypeOf(empty) + for i := 0; i < tType.NumField(); i++ { + structField := tType.Field(i) + jsonTag := structField.Tag.Get("json") + tagName := strings.Split(jsonTag, ",")[0] + + if tagName == "-" { + continue + } + + if tagName == field { + return &valueOutputWriter{ + outputType: tType, + field: structField.Name, + }, nil + } } + + return nil, fmt.Errorf("field \"%s\" not supported for output type: %s", field, tType.Name()) } type valueOutputWriter struct { - Field string + outputType reflect.Type + field string } var _ FormatWriter = (*valueOutputWriter)(nil) func (j *valueOutputWriter) Write(value any, writer io.Writer) error { - buildPtr, test := value.(*shared.Build) - if test { - found, err := getBuildValuePtr(buildPtr, j.Field) - if err != nil { - return err - } - _, err = fmt.Fprintf(writer, "%v\n", found) - return err - } - - build, test := value.(shared.Build) - if test { - found, err := getBuildValue(build, j.Field) - if err != nil { - return err - } - _, err = fmt.Fprintf(writer, "%v\n", found) - return err + v := reflect.ValueOf(value) + if v.Kind() == reflect.Ptr { + v = v.Elem() } - builds, test := value.([]shared.Build) - if test { - for _, b := range builds { - found, err := getBuildValue(b, j.Field) - if err != nil { - return err - } - _, err = fmt.Fprintf(writer, "%v\n", found) - if err != nil { - return err - } - } - - return nil + fieldValue := v.FieldByName(j.field) + if !fieldValue.IsValid() { + return fmt.Errorf("field %s not found", j.field) } - // default to just writing the text - _, err := fmt.Fprintf(writer, "%v\n", value) - return err -} - -func getBuildValuePtr(build *shared.Build, key string) (any, error) { - return getBuildValue(*build, key) -} + fmt.Fprintln(writer, fieldValue.Interface()) -func getBuildValue(build shared.Build, key string) (any, error) { - switch strings.ToLower(key) { - case "buildid": - return build.BuildID, nil - default: - return nil, fmt.Errorf(fmt.Sprintf("key %s not supported", key)) - } + return nil }