Skip to content
37 changes: 21 additions & 16 deletions cmd/harbor/root/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ package root

import (
"fmt"
"io"
"time"
"log/slog"

"github.com/goharbor/harbor-cli/cmd/harbor/root/artifact"
"github.com/goharbor/harbor-cli/cmd/harbor/root/configurations"
Expand All @@ -37,16 +36,18 @@ import (
"github.com/goharbor/harbor-cli/cmd/harbor/root/tag"
"github.com/goharbor/harbor-cli/cmd/harbor/root/user"
"github.com/goharbor/harbor-cli/cmd/harbor/root/webhook"
"github.com/goharbor/harbor-cli/pkg/logger"
"github.com/goharbor/harbor-cli/pkg/utils"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)

var (
output string
cfgFile string
verbose bool
logFormat string
output string
cfgFile string
verbose bool
)

func RootCmd() *cobra.Command {
Expand All @@ -68,23 +69,22 @@ harbor help
// Initialize configuration
utils.InitConfig(cfgFile, userSpecifiedConfig)

// Conditionally set the timestamp format only in verbose mode
formatter := &logrus.TextFormatter{}
// Sets up logging
logger.Setup(verbose, logFormat)

if verbose {
formatter.FullTimestamp = true
formatter.TimestampFormat = time.RFC3339
logrus.SetLevel(logrus.DebugLevel)
} else {
logrus.SetOutput(io.Discard)
}
logrus.SetFormatter(formatter)
// Logging Flags
arr := make([]any, 0) // slog requires any since the slog.Debug takes in (string, ...any)
cmd.Flags().VisitAll(func(f *pflag.Flag) {
arr = append(arr, f.Name, f.Value.String())
})
slog.Debug("Flags: ", arr...)

return nil
},
}

root.PersistentFlags().StringVarP(&output, "output-format", "o", "", "Output format. One of: json|yaml")
root.PersistentFlags().StringVarP(&logFormat, "log-format", "l", "text", "Output format for logging. One of: json|text")
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file (default is $HOME/.config/harbor-cli/config.yaml)")
root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")

Expand All @@ -93,6 +93,11 @@ harbor help
fmt.Println(err.Error())
}

err = viper.BindPFlag("log-format", root.PersistentFlags().Lookup("log-format"))
if err != nil {
fmt.Println(err.Error())
}

err = viper.BindPFlag("config", root.PersistentFlags().Lookup("config"))
if err != nil {
fmt.Println(err.Error())
Expand Down
143 changes: 143 additions & 0 deletions pkg/logger/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright Project Harbor Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package logger

import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"sync"

"github.com/charmbracelet/lipgloss"
)

// Needs to be functions, otherwise doesn't work
var (
debugStyle = func() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color("8")) }
infoStyle = func() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color("10")) }
warnStyle = func() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color("11")) }
errorStyle = func() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color("9")) }
)

type PrettyHandler struct {
mu sync.Mutex
out io.Writer
level slog.Leveler
preAttrs []slog.Attr // retained from WithAttrs calls
groups []string // retained from WithGroup calls
}

func NewPrettyHandler(out io.Writer, level slog.Leveler) *PrettyHandler {
return &PrettyHandler{
out: out,
level: level,
}
}

func (h *PrettyHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level.Level()
}

func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()

timestamp := r.Time.Format("15:04:05")
level := formatLevel(r.Level)

_, err := fmt.Fprintf(
h.out,
"%s %s %s\n",
fmt.Sprintf("%s |", timestamp),
level,
r.Message,
)
if err != nil {
return err
}

// Merge pre-attached attrs with record attrs.
var attrs []slog.Attr
attrs = append(attrs, h.preAttrs...)
r.Attrs(func(a slog.Attr) bool {
attrs = append(attrs, a)
return true
})

if len(attrs) == 0 {
return nil
}

// Compute column width across all attrs.
maxKey := 0
for _, a := range attrs {
if k := len(h.qualifiedKey(a.Key)); k > maxKey {
maxKey = k
}
}

for _, a := range attrs {
_, err = fmt.Fprintf(h.out, " %s : %v\n",
fmt.Sprintf("%-*s", maxKey, h.qualifiedKey(a.Key)),
a.Value.String(),
)
if err != nil {
return err
}
}
return nil
}

func (h *PrettyHandler) qualifiedKey(key string) string {
if len(h.groups) == 0 {
return key
}
return strings.Join(h.groups, ".") + "." + key
}

func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
clone := *h
clone.preAttrs = make([]slog.Attr, len(h.preAttrs)+len(attrs))
copy(clone.preAttrs, h.preAttrs)
copy(clone.preAttrs[len(h.preAttrs):], attrs)
return &clone
}

func (h *PrettyHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
clone := *h
clone.groups = make([]string, len(h.groups)+1)
copy(clone.groups, h.groups)
clone.groups[len(h.groups)] = name
return &clone
}

func formatLevel(level slog.Level) string {
switch level {
case slog.LevelDebug:
return debugStyle().Render("[ DEBUG ]")
case slog.LevelInfo:
return infoStyle().Render("[ INFO ]") // space is intentional for symmetry
case slog.LevelWarn:
return warnStyle().Render("[ WARN ]") // space is intentional for symmetry
case slog.LevelError:
return errorStyle().Render("[ ERROR ]")
default:
return level.String()
}
}
38 changes: 38 additions & 0 deletions pkg/logger/logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright Project Harbor Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package logger

import (
"io"
"log/slog"
"os"
)

func Setup(verbose bool, format string) {
outp := io.Discard
if verbose {
outp = os.Stderr
}

var handler slog.Handler
if format == "json" {
handler = slog.NewJSONHandler(outp, &slog.HandlerOptions{Level: slog.LevelDebug})
} else {
// Custom Text Handler
handler = NewPrettyHandler(outp, slog.LevelDebug)
Comment on lines +29 to +33
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The --log-format flag does not validate that the format value is one of the supported options ("json" or "text"). Currently, any invalid value silently defaults to the text handler. Consider adding explicit validation in the Setup function to reject unsupported formats, or document this fallback behavior more clearly. According to the PR discussion, only "json" and "text" should be accepted values.

Suggested change
if format == "json" {
handler = slog.NewJSONHandler(outp, &slog.HandlerOptions{Level: slog.LevelDebug})
} else {
// Custom Text Handler
handler = NewPrettyHandler(outp, slog.LevelDebug)
switch format {
case "json":
handler = slog.NewJSONHandler(outp, &slog.HandlerOptions{Level: slog.LevelDebug})
case "text":
// Custom Text Handler
handler = NewPrettyHandler(outp, slog.LevelDebug)
default:
panic(`unsupported log format: ` + format + `; supported formats are "json" and "text"`)

Copilot uses AI. Check for mistakes.
}

logger := slog.New(handler)
slog.SetDefault(logger)
}
Loading