feat(config): add configurable startup banner output (#893)
This commit is contained in:
@@ -221,6 +221,21 @@ air -- -h
|
||||
air -c .air.toml -- -h
|
||||
```
|
||||
|
||||
### Startup banner
|
||||
|
||||
Use `misc.startup_banner` to control what Air prints at startup.
|
||||
|
||||
```toml
|
||||
[misc]
|
||||
# Not set (default): show built-in ASCII banner with version.
|
||||
|
||||
# Set to empty string: print nothing.
|
||||
startup_banner = ""
|
||||
|
||||
# Set to custom text: print this text instead of the built-in banner.
|
||||
# startup_banner = "API watcher"
|
||||
```
|
||||
|
||||
### Entrypoint
|
||||
|
||||
Use `build.entrypoint` to point at the binary generated by `build.cmd` and describe how it should be executed. The value can be either a string (just the executable) or an array of strings. When using an array, the first element is the executable (resolved relative to `root` unless it lacks a path separator, in which case `$PATH` is consulted) and every subsequent element is treated as a default argument. Values from `build.args_bin` and the command line are appended after the inline arguments. The legacy `build.bin` field is deprecated and will be removed in a future release, so prefer the entrypoint form going forward.
|
||||
|
||||
@@ -87,6 +87,8 @@ runner = "green"
|
||||
[misc]
|
||||
# Delete tmp directory on exit
|
||||
clean_on_exit = true
|
||||
# Startup banner text. Set to "" to hide the banner.
|
||||
# startup_banner = ""
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = true
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/air-verse/air/runner"
|
||||
@@ -73,9 +74,9 @@ func GetVersionInfo() versionInfo { //revive:disable:unexported-return
|
||||
}
|
||||
}
|
||||
|
||||
func printSplash() {
|
||||
func defaultSplashText() string {
|
||||
versionInfo := GetVersionInfo()
|
||||
fmt.Fprintf(os.Stderr, `
|
||||
return fmt.Sprintf(`
|
||||
__ _ ___
|
||||
/ /\ | | | |_)
|
||||
/_/--\ |_| |_| \_ %s, built with Go %s
|
||||
@@ -83,6 +84,49 @@ func printSplash() {
|
||||
`, versionInfo.airVersion, versionInfo.goVersion)
|
||||
}
|
||||
|
||||
func versionLineText() string {
|
||||
versionInfo := GetVersionInfo()
|
||||
return fmt.Sprintf("air %s, built with Go %s\n", versionInfo.airVersion, versionInfo.goVersion)
|
||||
}
|
||||
|
||||
func startupBannerText(cfg *runner.Config) string {
|
||||
if cfg == nil || cfg.Misc.StartupBanner == nil {
|
||||
return defaultSplashText()
|
||||
}
|
||||
return *cfg.Misc.StartupBanner
|
||||
}
|
||||
|
||||
func printStartupBanner(cfg *runner.Config, respectSilent bool) {
|
||||
if cfg != nil && respectSilent && cfg.Log.Silent {
|
||||
return
|
||||
}
|
||||
banner := startupBannerText(cfg)
|
||||
if banner == "" {
|
||||
return
|
||||
}
|
||||
fmt.Fprint(os.Stderr, banner)
|
||||
if !strings.HasSuffix(banner, "\n") {
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func printVersionOutput(cfg *runner.Config) {
|
||||
if cfg == nil || cfg.Misc.StartupBanner == nil {
|
||||
fmt.Fprint(os.Stderr, defaultSplashText())
|
||||
return
|
||||
}
|
||||
|
||||
banner := *cfg.Misc.StartupBanner
|
||||
if banner != "" {
|
||||
fmt.Fprint(os.Stderr, banner)
|
||||
if !strings.HasSuffix(banner, "\n") {
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprint(os.Stderr, versionLineText())
|
||||
}
|
||||
|
||||
func main() {
|
||||
switch colorMode {
|
||||
case "always":
|
||||
@@ -94,22 +138,25 @@ func main() {
|
||||
default:
|
||||
log.Fatal("unsupported color mode: use always, never, auto")
|
||||
}
|
||||
|
||||
if showVersion {
|
||||
printSplash()
|
||||
cfg, err := runner.InitConfigForDisplay(cfgPath, cmdArgs)
|
||||
if err == nil {
|
||||
printVersionOutput(cfg)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(os.Stderr, defaultSplashText())
|
||||
return
|
||||
}
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||
|
||||
var err error
|
||||
cfg, err := runner.InitConfig(cfgPath, cmdArgs)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return
|
||||
}
|
||||
if !cfg.Log.Silent {
|
||||
printSplash()
|
||||
}
|
||||
printStartupBanner(cfg, true)
|
||||
if debugMode && !cfg.Log.Silent {
|
||||
fmt.Println("[debug] mode")
|
||||
}
|
||||
|
||||
+35
-9
@@ -167,7 +167,8 @@ type cfgColor struct {
|
||||
}
|
||||
|
||||
type cfgMisc struct {
|
||||
CleanOnExit bool `toml:"clean_on_exit" usage:"Delete tmp directory on exit"`
|
||||
CleanOnExit bool `toml:"clean_on_exit" usage:"Delete tmp directory on exit"`
|
||||
StartupBanner *string `toml:"startup_banner" usage:"Custom startup banner text; set to empty string to hide banner"`
|
||||
}
|
||||
|
||||
type cfgScreen struct {
|
||||
@@ -197,7 +198,39 @@ func (t sliceTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Va
|
||||
}
|
||||
|
||||
// InitConfig initializes the configuration.
|
||||
func InitConfig(path string, cmdArgs map[string]TomlInfo) (cfg *Config, err error) {
|
||||
func InitConfig(path string, cmdArgs map[string]TomlInfo) (*Config, error) {
|
||||
ret, err := initConfigWithoutPreprocess(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = ret.preprocess(cmdArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
warnDeprecatedBin(ret)
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// InitConfigForDisplay initializes config without preprocess side effects.
|
||||
func InitConfigForDisplay(path string, cmdArgs map[string]TomlInfo) (*Config, error) {
|
||||
ret, err := initConfigWithoutPreprocess(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cmdArgs != nil {
|
||||
ret.withArgs(cmdArgs)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func initConfigWithoutPreprocess(path string) (*Config, error) {
|
||||
var (
|
||||
cfg *Config
|
||||
err error
|
||||
)
|
||||
|
||||
if path == "" {
|
||||
cfg, err = defaultPathConfig()
|
||||
if err != nil {
|
||||
@@ -226,13 +259,6 @@ func InitConfig(path string, cmdArgs map[string]TomlInfo) (cfg *Config, err erro
|
||||
if err = applyPlatformOverrides(ret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = ret.preprocess(cmdArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
warnDeprecatedBin(ret)
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -379,6 +379,115 @@ func TestDefaultConfigForOS(t *testing.T) {
|
||||
if linuxCfg.Build.Bin != "./tmp/main" {
|
||||
t.Fatalf("linux bin mismatch: got %q", linuxCfg.Build.Bin)
|
||||
}
|
||||
if linuxCfg.Misc.StartupBanner != nil {
|
||||
t.Fatalf("startup_banner should default to nil, got %v", *linuxCfg.Misc.StartupBanner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithArgsSetsStartupBanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("custom text", func(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
args := ParseConfigFlag(fs)
|
||||
value := "Watcher A"
|
||||
info, ok := args["misc.startup_banner"]
|
||||
if !ok {
|
||||
t.Fatal("misc.startup_banner flag mapping missing")
|
||||
}
|
||||
*info.Value = value
|
||||
|
||||
cfg.withArgs(args)
|
||||
|
||||
if cfg.Misc.StartupBanner == nil {
|
||||
t.Fatal("startup_banner should be set")
|
||||
}
|
||||
if got := *cfg.Misc.StartupBanner; got != value {
|
||||
t.Fatalf("startup_banner mismatch: got %q want %q", got, value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
args := ParseConfigFlag(fs)
|
||||
info, ok := args["misc.startup_banner"]
|
||||
if !ok {
|
||||
t.Fatal("misc.startup_banner flag mapping missing")
|
||||
}
|
||||
*info.Value = ""
|
||||
|
||||
cfg.withArgs(args)
|
||||
|
||||
if cfg.Misc.StartupBanner == nil {
|
||||
t.Fatal("startup_banner should be set")
|
||||
}
|
||||
if got := *cfg.Misc.StartupBanner; got != "" {
|
||||
t.Fatalf("startup_banner mismatch: got %q want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInitConfigForDisplayStartupBanner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("reads empty string from config", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfgPath := filepath.Join(tmpDir, ".air.toml")
|
||||
cfgContent := `
|
||||
[misc]
|
||||
startup_banner = ""
|
||||
`
|
||||
if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := InitConfigForDisplay(cfgPath, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InitConfigForDisplay returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Misc.StartupBanner == nil {
|
||||
t.Fatal("startup_banner should be set")
|
||||
}
|
||||
if got := *cfg.Misc.StartupBanner; got != "" {
|
||||
t.Fatalf("startup_banner mismatch: got %q want empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("applies command arg override", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cfgPath := filepath.Join(tmpDir, ".air.toml")
|
||||
cfgContent := `
|
||||
[misc]
|
||||
startup_banner = "FromConfig"
|
||||
`
|
||||
if err := os.WriteFile(cfgPath, []byte(cfgContent), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
args := ParseConfigFlag(fs)
|
||||
value := "FromFlag"
|
||||
info, ok := args["misc.startup_banner"]
|
||||
if !ok {
|
||||
t.Fatal("misc.startup_banner flag mapping missing")
|
||||
}
|
||||
*info.Value = value
|
||||
|
||||
cfg, err := InitConfigForDisplay(cfgPath, args)
|
||||
if err != nil {
|
||||
t.Fatalf("InitConfigForDisplay returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Misc.StartupBanner == nil {
|
||||
t.Fatal("startup_banner should be set")
|
||||
}
|
||||
if got := *cfg.Misc.StartupBanner; got != value {
|
||||
t.Fatalf("startup_banner mismatch: got %q want %q", got, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPlatformBuildOverridesSelection(t *testing.T) {
|
||||
|
||||
+6
-1
@@ -410,7 +410,12 @@ func setValue2Struct(v reflect.Value, fieldName string, value string) {
|
||||
b, _ := strconv.ParseBool(value)
|
||||
field.SetBool(b)
|
||||
case reflect.Ptr:
|
||||
field.SetString(value)
|
||||
if field.Type().Elem().Kind() != reflect.String {
|
||||
log.Fatalf("unsupported pointer type %s", field.Type().Elem().Kind())
|
||||
}
|
||||
v := reflect.New(field.Type().Elem())
|
||||
v.Elem().SetString(value)
|
||||
field.Set(v)
|
||||
default:
|
||||
log.Fatalf("unsupported type %s", v.FieldByName(fields[0]).Kind())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user